import { readFile, writeFile } from 'fs/promises';
import { basename, dirname, extname, resolve, join } from 'path';
import { build } from 'esbuild';
import { tmpName } from 'tmp-promise';
import { FunctionBundlingUserError } from '../../../../utils/error.js';
import { getPathWithExtension, safeUnlink } from '../../../../utils/fs.js';
import { glob } from '../../../../utils/matching.js';
import { RUNTIME } from '../../../runtime.js';
import { CJS_SHIM } from '../../utils/esm_cjs_compat.js';
import { getFileExtensionForFormat, MODULE_FORMAT } from '../../utils/module_format.js';
import { NODE_BUNDLER } from '../types.js';
import { getBundlerTarget, getModuleFormat } from './bundler_target.js';
import { getNativeModulesPlugin } from './plugin_native_modules.js';
import { getNodeBuiltinPlugin } from './plugin_node_builtin.js';
// Maximum number of log messages that an esbuild instance will produce. This
// limit is important to avoid out-of-memory errors due to too much data being
// sent in the Go<>Node IPC channel.
export const ESBUILD_LOG_LIMIT = 10;
// When resolving imports with no extension (e.g. require('./foo')), these are
// the extensions that esbuild will look for, in this order.
const RESOLVE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', '.json'];
/**
 * Our own `includedFiles` syntax is slightly different from what esbuild expects as `externals`.
 *
 * Turns !node_modules/test/** into test
 * and !lang/en.* into ./lang/en.*.
 * esbuild can't handle multiple globs in a single pattern, so we resolve them instead.
 */
const includedFilesToEsbuildExternals = async (includedFiles, baseDir) => {
    const exclusions = includedFiles
        .filter((pattern) => pattern.startsWith('!'))
        .map((pattern) => pattern.slice(1))
        // esbuild expects relative paths
        .map((pattern) => `./${pattern}`)
        // esbuild treats * the same as glob treats **, so this replacement is safe
        .map((pattern) => pattern.replace(/\*\*/g, '*').replace(/\*(\\\*)+/g, '*'));
    const result = [];
    for (const pattern of exclusions) {
        // esbuild expects modules to be passed in as module names, not paths
        const nodeModulesMatch = pattern.match(/^\.\/node_modules\/(([^/]+)(\/[^/*]+)?)*/);
        if (nodeModulesMatch !== null) {
            const [, moduleName] = nodeModulesMatch;
            result.push(moduleName);
            continue;
        }
        const hasMultipleGlobs = pattern.indexOf('*') !== pattern.lastIndexOf('*');
        if (hasMultipleGlobs) {
            const resolved = await glob(pattern, {
                noglobstar: true,
                cwd: baseDir,
            });
            result.push(...resolved);
        }
        else {
            result.push(pattern);
        }
    }
    return result;
};
export const bundleJsFile = async function ({ additionalModulePaths, config, externalModules = [], featureFlags, ignoredModules = [], mainFile, name, srcDir, srcFile, runtimeAPIVersion, }) {
    // We use a temporary directory as the destination for esbuild files to avoid
    // any naming conflicts with files generated by other functions.
    const targetDirectory = await tmpName();
    // files matching negated patterns, like `!lang/en.*`, should be excluded from the bundle
    const excludedFiles = await includedFilesToEsbuildExternals(config.includedFiles ?? [], srcDir);
    // De-duping external and ignored modules.
    const external = [...new Set([...externalModules, ...ignoredModules, ...excludedFiles])];
    // To be populated by the native modules plugin with the names, versions and
    // paths of any Node modules with native dependencies.
    const nativeNodeModules = {};
    // The list of esbuild plugins to enable for this build.
    const plugins = [getNodeBuiltinPlugin(), getNativeModulesPlugin(nativeNodeModules)];
    // The version of ECMAScript to use as the build target. This will determine
    // whether certain features are transpiled down or left untransformed.
    const nodeTarget = getBundlerTarget(config.nodeVersion);
    // esbuild will format `sources` relative to the sourcemap file, which lives
    // in `destFolder`. We use `sourceRoot` to establish that relation. They are
    // URLs, not paths, so even on Windows they should use forward slashes.
    const sourceRoot = targetDirectory.replace(/\\/g, '/');
    // Configuring the output format of esbuild. The `includedFiles` array we get
    // here contains additional paths to include with the bundle, like the path
    // to a `package.json` with {"type": "module"} in case of an ESM function.
    const { includedFiles: includedFilesFromModuleDetection, moduleFormat } = await getModuleFormat({
        srcDir,
        featureFlags,
        extension: extname(mainFile),
        runtimeAPIVersion,
        configVersion: config.nodeVersion,
    });
    // The extension of the output file.
    const outputExtension = getFileExtensionForFormat(moduleFormat, featureFlags, runtimeAPIVersion);
    try {
        const { metafile = { inputs: {}, outputs: {} }, warnings } = await build({
            banner: moduleFormat === MODULE_FORMAT.ESM ? { js: CJS_SHIM } : undefined,
            bundle: true,
            entryPoints: [srcFile],
            external,
            format: moduleFormat,
            logLevel: 'warning',
            logLimit: ESBUILD_LOG_LIMIT,
            metafile: true,
            nodePaths: additionalModulePaths,
            outdir: targetDirectory,
            outExtension: { '.js': outputExtension },
            platform: 'node',
            plugins,
            resolveExtensions: RESOLVE_EXTENSIONS,
            sourcemap: Boolean(config.nodeSourcemap),
            sourceRoot,
            target: [nodeTarget],
        });
        const bundlePaths = getBundlePaths({
            destFolder: targetDirectory,
            outputs: metafile.outputs,
            srcFile,
            outputExtension,
        });
        // workaround for https://github.com/evanw/esbuild/issues/3328
        await Promise.all(Object.keys(metafile.outputs)
            .filter((filename) => filename.endsWith('.js'))
            .map(async (filename) => {
            const content = await readFile(filename, { encoding: 'utf-8' });
            const updated = content.replace(`
var __glob = (map) => (path) => {
  var fn = map[path];
  if (fn) return fn();
  throw new Error("Module not found in bundle: " + path);
};
      `.trim(), `
var __glob = (map) => (path) => {
  var fn = map[path] || map[path + '.js'] || map[path + '.json'] || map[path + '/index.js'] || map[path + '/index.json'];
  if (fn)
    return fn();
  throw new Error("Module not found in bundle: " + path);
};
            `.trim());
            await writeFile(filename, updated, { encoding: 'utf-8' });
        }));
        const inputs = Object.keys(metafile.inputs).map((path) => resolve(path));
        const cleanTempFiles = getCleanupFunction([...bundlePaths.keys()]);
        const additionalPaths = includedFilesFromModuleDetection;
        const updatedWarnings = warnings.map((warning) => {
            if (warning.id === 'empty-import-meta') {
                return {
                    ...warning,
                    text: `"import.meta" is not available and will be empty, use __dirname instead`,
                };
            }
            return warning;
        });
        return {
            additionalPaths,
            bundlePaths,
            cleanTempFiles,
            inputs,
            moduleFormat,
            nativeNodeModules,
            outputExtension,
            warnings: updatedWarnings,
        };
    }
    catch (error) {
        throw FunctionBundlingUserError.addCustomErrorInfo(error, {
            functionName: name,
            runtime: RUNTIME.JAVASCRIPT,
            bundler: NODE_BUNDLER.ESBUILD,
        });
    }
};
// Takes the `outputs` object produced by esbuild and returns a Map with the
// absolute paths of the generated files as keys, and the paths that those
// files should take in the generated bundle as values. This is compatible
// with the `aliases` format used upstream.
const getBundlePaths = ({ destFolder, outputExtension, outputs, srcFile, }) => {
    const bundleFilename = basename(srcFile, extname(srcFile)) + outputExtension;
    const mainFileDirectory = dirname(srcFile);
    const bundlePaths = new Map();
    // The paths returned by esbuild are relative to the current directory, which
    // is a problem on Windows if the target directory is in a different drive
    // letter. To get around that, instead of using `path.resolve`, we compute
    // the absolute path by joining `destFolder` with the `basename` of each
    // entry of the `outputs` object.
    Object.entries(outputs).forEach(([path, output]) => {
        const filename = basename(path);
        const extension = extname(path);
        const absolutePath = join(destFolder, filename);
        if (output.entryPoint && basename(output.entryPoint) === basename(srcFile)) {
            // Ensuring the main file has the right extension.
            const normalizedSrcFile = getPathWithExtension(srcFile, outputExtension);
            bundlePaths.set(absolutePath, normalizedSrcFile);
        }
        else if (extension === outputExtension || filename === `${bundleFilename}.map`) {
            bundlePaths.set(absolutePath, join(mainFileDirectory, filename));
        }
    });
    return bundlePaths;
};
const getCleanupFunction = (paths) => async () => {
    await Promise.all(paths.filter(Boolean).map(safeUnlink));
};
