/* global Buffer */

import { extname, basename, resolve } from "node:path";

import { minify } from "terser";
import { fileURLToPath } from "node:url";

import { optimizeComponent } from "./opt.js";

import { $init, generate } from "../../obj/js-component-bindgen-component.js";
import {
    readFile,
    spawnIOTmp,
    setShowSpinner,
    getShowSpinner,
    writeFiles,
    styleText,
    ASYNC_WASI_IMPORTS,
    ASYNC_WASI_EXPORTS,
    DEFAULT_ASYNC_MODE,
} from "../common.js";
import { $init as wasmToolsInit, tools } from "../../obj/wasm-tools.js";
const { componentEmbed, componentNew } = tools;

import ora from "#ora";

import { isWindows } from "../common.js";

// These re-exports exist to avoid breaking backwards compatibility
export { types, guestTypes, typesComponent } from "./types.js";

export async function transpile(witPath, opts, program) {
    const varIdx = program?.parent.rawArgs.indexOf("--");
    if (varIdx !== undefined && varIdx !== -1) {
        opts.optArgs = program.parent.rawArgs.slice(varIdx + 1);
    }

    let component;
    if (!opts.stub) {
        component = await readFile(witPath);
    } else {
        await wasmToolsInit;
        component = componentNew(
            componentEmbed({
                dummy: true,
                witPath: (isWindows ? "//?/" : "") + resolve(witPath),
            }),
            [],
        );
    }

    if (!opts.quiet) {
        setShowSpinner(true);
    }
    if (!opts.name) {
        opts.name = basename(witPath.slice(0, -extname(witPath).length || Infinity));
    }
    if (opts.map) {
        opts.map = Object.fromEntries(opts.map.map((mapping) => mapping.split("=")));
    }

    if (opts.asyncWasiImports) {
        opts.asyncImports = ASYNC_WASI_IMPORTS.concat(opts.asyncImports || []);
    }
    if (opts.asyncWasiExports) {
        opts.asyncExports = ASYNC_WASI_EXPORTS.concat(opts.asyncExports || []);
    }

    const { files } = await transpileComponent(component, opts);
    await writeFiles(files, opts.quiet ? false : "Transpiled JS Component Files");
}

/**
 * @param {Uint8Array} source
 * @returns {Promise<Uint8Array>}
 */
async function wasm2Js(source) {
    const wasm2jsPath = fileURLToPath(import.meta.resolve("binaryen/bin/wasm2js"));

    try {
        return await spawnIOTmp(wasm2jsPath, source, ["-Oz", "-o"]);
    } catch (e) {
        if (e.toString().includes("BasicBlock requested")) {
            return wasm2Js(source);
        }
        throw e;
    }
}

/**
 * Execute the bundled pre-transpiled component that can perform component transpilation,
 * for the given component.
 *
 * @param {Uint8Array} component
 * @param {{
 *   name: string,
 *   instantiation?: 'async' | 'sync',
 *   importBindings?: 'js' | 'optimized' | 'hybrid' | 'direct-optimized',
 *   map?: Record<string, string>,
 *   asyncMode?: string,
 *   asyncImports?: string[],
 *   asyncExports?: string[],
 *   validLiftingOptimization?: bool,
 *   tracing?: bool,
 *   nodejsCompat?: bool,
 *   tlaCompat?: bool,
 *   base64Cutoff?: bool,
 *   js?: bool,
 *   minify?: bool,
 *   optimize?: bool,
 *   namespacedExports?: bool,
 *   outDir?: string,
 *   multiMemory?: bool,
 *   experimentalIdlImports?: bool,
 *   optArgs?: string[],
 *   wasmOptBin?: string[],
 * }} opts
 * @returns {Promise<{ files: { [filename: string]: Uint8Array }, imports: string[], exports: [string, 'function' | 'instance'][] }>}
 */
export async function transpileComponent(component, opts = {}) {
    await $init;
    if (opts.instantiation) {
        opts.wasiShim = false;
    }

    let spinner;
    const showSpinner = getShowSpinner();

    if (opts.optimize) {
        if (showSpinner) {
            setShowSpinner(true);
        }
        ({ component } = await optimizeComponent(component, opts));
    }

    if (opts.wasiShim !== false) {
        opts.map = Object.assign(
            {
                "wasi:cli/*": "@bytecodealliance/preview2-shim/cli#*",
                "wasi:clocks/*": "@bytecodealliance/preview2-shim/clocks#*",
                "wasi:filesystem/*": "@bytecodealliance/preview2-shim/filesystem#*",
                "wasi:http/*": "@bytecodealliance/preview2-shim/http#*",
                "wasi:io/*": "@bytecodealliance/preview2-shim/io#*",
                "wasi:random/*": "@bytecodealliance/preview2-shim/random#*",
                "wasi:sockets/*": "@bytecodealliance/preview2-shim/sockets#*",
            },
            opts.map || {},
        );
    }

    let instantiation = null;

    // Let's define `instantiation` from `--instantiation` if it's present.
    if (opts.instantiation) {
        instantiation = { tag: opts.instantiation };
    }
    // Otherwise, if `--js` is present, an `instantiate` function is required.
    else if (opts.js) {
        instantiation = { tag: "async" };
    }

    // Get the configured async mode then transform it into what the types component expects
    // Build list of async imports/exports
    let asyncImports = new Set([...(opts.asyncImports ?? [])]);
    let asyncExports = new Set([...(opts.asyncExports ?? [])]);
    let asyncMode = opts.asyncMode ?? DEFAULT_ASYNC_MODE;
    if (asyncMode === "sync" && asyncExports.size > 0) {
        throw new Error("async exports cannot be specified in sync mode (consider adding --async-mode=jspi)");
    }
    if (asyncMode === "sync" && asyncImports.size > 0) {
        throw new Error("async imports cannot be specified in sync mode (consider adding --async-mode=jspi)");
    }
    let asyncModeObj;
    if (asyncMode === "sync") {
        asyncModeObj = null;
    } else if (asyncMode === "jspi") {
        asyncModeObj = {
            tag: "jspi",
            val: {
                imports: [...asyncImports],
                exports: [...asyncExports],
            },
        };
    } else {
        throw new Error(`invalid/unrecognized async mode [${asyncMode}]`);
    }

    let { files, imports, exports } = generate(component, {
        name: opts.name ?? "component",
        map: Object.entries(opts.map ?? {}),
        instantiation,
        asyncMode: asyncModeObj,
        importBindings: opts.importBindings ? { tag: opts.importBindings } : null,
        validLiftingOptimization: opts.validLiftingOptimization ?? false,
        tracing: opts.tracing ?? false,
        noNodejsCompat: opts.nodejsCompat === false,
        noTypescript: opts.typescript === false,
        tlaCompat: opts.tlaCompat ?? false,
        base64Cutoff: opts.js ? 0 : (opts.base64Cutoff ?? 5000),
        noNamespacedExports: opts.namespacedExports === false,
        multiMemory: opts.multiMemory === true,
        idlImports: opts.experimentalIdlImports === true,
    });

    let outDir = (opts.outDir ?? "").replace(/\\/g, "/");
    if (!outDir.endsWith("/") && outDir !== "") {
        outDir += "/";
    }
    files = files.map(([name, source]) => [`${outDir}${name}`, source]);

    const jsFile = files.find(([name]) => name.endsWith(".js"));

    // Generate code for the `--js` option.
    //
    // `--js` can be called with or without `--instantiation`. The generated code
    // isn't exactly the same!
    //
    // `--js` needs an `instantiate` function to work, so it might look like
    // `--instantiation` is always implied, but actually no. It is correct
    // that when `--js` is present, an `instantiate` function _is_ generated,
    // but it doesn't mean that we expect the function to be used, it's simply
    // not exported, plus `instantiate` is automatically called (if `--tla-compat`
    // is `false`). When `--instantiation` is missing, functions are exported
    // with the `export` directive, and imports are imported with the `import`
    // directive. When `--instantiation` is present, there is no `export` and no
    // `import`: only a single exported `instantiate` function.
    //
    // Basically, we get this:
    //
    // * `--js` only:
    //   * `instantiate` is renamed to `_instantiate`,
    //   * A new `instantiate` function is created, that calls `_instantiate` with
    //     the correct imports (which are ASM.js code) and returns the exports,
    //   * A new `$init` function is created, that calls `instantiate` and maps
    //     the returned exports to their respective trampolines,
    //   * Trampolines are exported,
    //   * `$init` is called automatically.
    //
    // * `--js` with `--tla-compat`:
    //   * Same as with `--js` only, except that `$init` is exported instead of
    //     being called immediately.
    //
    // * `--js` with `--instantiation[=async]`:
    //   * `instantiate` is renamed to `_instantiate`,
    //   * A new `instantiate` function is created, that calls `_instantiate` with
    //     the correct imports (which are ASM.js code) and returns the exports,
    //   * `instantiate` is exported.
    //
    // * `--js` with `--instantiation=sync`:
    //   * Same as `--js` with `--instantiation[=async]`, except that
    //     `_instantiate` and `instantiate` are non-async.
    //
    // Be careful with the variables: `opts.instantiation` reflects the presence
    // or the absence of the `--instantiation` flag, whilst `instantiation`
    // reflects how the `instantiate` function must be generated. We also use
    // `instantiation` to know whether the generated code must be async or
    // non-async.
    if (opts.js) {
        const withInstantiation = opts.instantiation !== undefined;
        const async_ = instantiation.tag == "async" ? "async " : "";
        const await_ = instantiation.tag == "async" ? "await " : "";

        // Format the previously generated code.
        const source = Buffer.from(jsFile[1])
            .toString("utf8")
            // update imports manging to match emscripten asm
            .replace(/exports(\d+)\['([^']+)']/g, (_, i, s) => `exports${i}['${asmMangle(s)}']`)
            .replace(/export (async )?function instantiate/, "$1function _instantiate");

        // Collect all Wasm files.
        const wasmFiles = files.filter(([name]) => name.endsWith(".wasm"));
        files = files.filter(([name]) => !name.endsWith(".wasm"));

        // Configure the spinner.
        let completed = 0;
        const spinnerText = () =>
            `${styleText("cyan", `${completed} / ${wasmFiles.length}`)} Running Binaryen wasm2js on Wasm core modules (this takes a while)...\n`;
        if (showSpinner) {
            spinner = ora({
                color: "cyan",
                spinner: "bouncingBar",
            }).start();
            spinner.text = spinnerText();
        }

        // Compile all Wasm modules into ASM.js codes.
        try {
            const asmFiles = await Promise.all(
                wasmFiles.map(async ([, source]) => {
                    const output = (await wasm2Js(source)).toString("utf8");
                    if (spinner) {
                        completed++;
                        spinner.text = spinnerText();
                    }
                    return output;
                }),
            );

            const asms = asmFiles
                .map(
                    (asm, nth) => `function asm${nth}(imports) {
  ${
      // strip and replace the asm instantiation wrapper
      asm
          .replace(/import \* as [^ ]+ from '[^']*';/g, "")
          .replace("function asmFunc(imports) {", "")
          .replace(/export var ([^ ]+) = ([^. ]+)\.([^ ]+);/g, "")
          .replace(/var retasmFunc = [\s\S]*$/, "")
          .replace(/var memasmFunc = new ArrayBuffer\(0\);/g, "")
          .replace("memory.grow = __wasm_memory_grow;", "")
          .trim()
  }`,
                )
                .join(",\n");

            // The `instantiate` function.
            const instantiateFunction = `${withInstantiation ? "export " : ""}${async_}function instantiate(imports) {
  const wasm_file_to_asm_index = {
    ${wasmFiles.map(([path], nth) => `'${basename(path)}': ${nth}`).join(",\n    ")}
  };

  return ${await_}_instantiate(
      module_name => wasm_file_to_asm_index[module_name],
      imports,
      (module_index, imports) => ({ exports: asmInit[module_index](imports) })
  );
}`;

            // If `--js` is used without `--instantiation`.
            let importDirectives = "";
            let exportDirectives = "";
            let exportTrampolines = "";
            let autoInstantiate = "";

            if (!withInstantiation) {
                importDirectives = imports
                    .map((import_file, nth) => `import * as import${nth} from '${import_file}';`)
                    .join("\n");

                if (exports.length > 0 || opts.tlaCompat) {
                    exportDirectives = `export {
${
    // Exporting `$init` must come first to not break the transpiling tests.
    opts.tlaCompat ? "  $init,\n" : ""
}${exports
                        .map(([name]) => {
                            if (name === asmMangle(name)) {
                                return `  ${name},`;
                            } else {
                                return `  ${asmMangle(name)} as '${name}',`;
                            }
                        })
                        .join("\n")}
}`;
                }

                exportTrampolines = `let ${exports
                    .filter(([, ty]) => ty === "function")
                    .map(([name]) => `_${asmMangle(name)}`)
                    .join(", ")};
${exports
    .map(([name, ty]) => {
        if (ty === "function") {
            return `\nfunction ${asmMangle(name)} () {
  return _${asmMangle(name)}.apply(this, arguments);
}`;
        } else {
            return `\nlet ${asmMangle(name)};`;
        }
    })
    .join("\n")}`;

                autoInstantiate = `${async_}function $init() {
  ( {
${exports
    .map(([name, ty]) => {
        if (ty === "function") {
            return `    '${name}': _${asmMangle(name)},`;
        } else if (asmMangle(name) === name) {
            return `    ${name},`;
        } else {
            return `    '${name}': ${asmMangle(name)},`;
        }
    })
    .join("\n")}
  } = ${await_}instantiate(
    {
${imports.map((import_file, nth) => `      '${import_file}': import${nth},`).join("\n")}
    }
  ) )
}

${opts.tlaCompat ? "" : `${await_}$init();`}`;
            }

            // Prepare the final generated code.
            const outSource = `${importDirectives}

${source}

const asmInit = [${asms}];

${exportTrampolines}

${instantiateFunction}

${exportDirectives}

${autoInstantiate}`;

            // Save the final generated code.
            jsFile[1] = Buffer.from(outSource);
        } finally {
            if (spinner) {
                spinner.stop();
            }
        }
    }

    if (opts.minify) {
        try {
            ({ code: jsFile[1] } = await minify(Buffer.from(jsFile[1]).toString("utf8"), {
                module: true,
                compress: {
                    ecma: 9,
                    unsafe: true,
                },
                mangle: {
                    keep_classnames: true,
                },
            }));
        } catch (err) {
            console.error(`error while minifying JS: ${err}`);
            throw err;
        }
    }

    return { files: Object.fromEntries(files), imports, exports };
}

// emscripten asm mangles specifiers to be valid identifiers
// for imports to match up we must do the same
// See https://github.com/WebAssembly/binaryen/blob/main/src/asmjs/asmangle.cpp
function asmMangle(name) {
    if (name === "") {
        return "$";
    }

    let mightBeKeyword = true;
    let i = 1;

    // Names must start with a character, $ or _
    switch (name[0]) {
        case "0":
        case "1":
        case "2":
        case "3":
        case "4":
        case "5":
        case "6":
        case "7":
        case "8":
        case "9": {
            name = "$" + name;
            i = 2;
            // fallthrough
        }
        case "$":
        case "_": {
            mightBeKeyword = false;
            break;
        }
        default: {
            let chNum = name.charCodeAt(0);
            if (!(chNum >= 97 && chNum <= 122) && !(chNum >= 65 && chNum <= 90)) {
                name = "$" + name.substr(1);
                mightBeKeyword = false;
            }
        }
    }

    // Names must contain only characters, digits, $ or _
    let len = name.length;
    for (; i < len; ++i) {
        switch (name[i]) {
            case "0":
            case "1":
            case "2":
            case "3":
            case "4":
            case "5":
            case "6":
            case "7":
            case "8":
            case "9":
            case "$":
            case "_": {
                mightBeKeyword = false;
                break;
            }
            default: {
                let chNum = name.charCodeAt(i);
                if (!(chNum >= 97 && chNum <= 122) && !(chNum >= 65 && chNum <= 90)) {
                    name = name.substr(0, i) + "_" + name.substr(i + 1);
                    mightBeKeyword = false;
                }
            }
        }
    }

    // Names must not collide with keywords
    if (mightBeKeyword && len >= 2 && len <= 10) {
        switch (name[0]) {
            case "a": {
                if (name == "arguments") {
                    return name + "_";
                }
                break;
            }
            case "b": {
                if (name == "break") {
                    return name + "_";
                }
                break;
            }
            case "c": {
                if (name == "case" || name == "continue" || name == "catch" || name == "const" || name == "class") {
                    return name + "_";
                }
                break;
            }
            case "d": {
                if (name == "do" || name == "default" || name == "debugger") {
                    return name + "_";
                }
                break;
            }
            case "e": {
                if (
                    name == "else" ||
                    name == "enum" ||
                    name == "eval" || // to be sure
                    name == "export" ||
                    name == "extends"
                ) {
                    return name + "_";
                }
                break;
            }
            case "f": {
                if (name == "for" || name == "false" || name == "finally" || name == "function") {
                    return name + "_";
                }
                break;
            }
            case "i": {
                if (
                    name == "if" ||
                    name == "in" ||
                    name == "import" ||
                    name == "interface" ||
                    name == "implements" ||
                    name == "instanceof"
                ) {
                    return name + "_";
                }
                break;
            }
            case "l": {
                if (name == "let") {
                    return name + "_";
                }
                break;
            }
            case "n": {
                if (name == "new" || name == "null") {
                    return name + "_";
                }
                break;
            }
            case "p": {
                if (name == "public" || name == "package" || name == "private" || name == "protected") {
                    return name + "_";
                }
                break;
            }
            case "r": {
                if (name == "return") {
                    return name + "_";
                }
                break;
            }
            case "s": {
                if (name == "super" || name == "static" || name == "switch") {
                    return name + "_";
                }
                break;
            }
            case "t": {
                if (name == "try" || name == "this" || name == "true" || name == "throw" || name == "typeof") {
                    return name + "_";
                }
                break;
            }
            case "v": {
                if (name == "var" || name == "void") {
                    return name + "_";
                }
                break;
            }
            case "w": {
                if (name == "with" || name == "while") {
                    return name + "_";
                }
                break;
            }
            case "y": {
                if (name == "yield") {
                    return name + "_";
                }
                break;
            }
        }
    }
    return name;
}

// see: https://github.com/vitest-dev/vitest/issues/6953#issuecomment-2505310022
if (typeof __vite_ssr_import_meta__ !== "undefined") {
    __vite_ssr_import_meta__.resolve = (path) => "file://" + globalCreateRequire(import.meta.url).resolve(path);
}
