"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.NodeModulesCollector = void 0;
const builder_util_1 = require("builder-util");
const childProcess = require("child_process");
const fs = require("fs-extra");
const fs_extra_1 = require("fs-extra");
const lazy_val_1 = require("lazy-val");
const path = require("path");
const hoist_1 = require("./hoist");
const moduleCache_1 = require("./moduleCache");
const packageManager_1 = require("./packageManager");
class NodeModulesCollector {
    constructor(rootDir, tempDirManager) {
        this.rootDir = rootDir;
        this.tempDirManager = tempDirManager;
        this.nodeModules = [];
        this.allDependencies = new Map();
        this.productionGraph = {};
        // Unified cache for all file system and module operations
        this.cache = (0, moduleCache_1.createModuleCache)();
        this.isHoisted = new lazy_val_1.Lazy(async () => {
            var _a;
            const { manager } = this.installOptions;
            const command = (0, packageManager_1.getPackageManagerCommand)(manager);
            const config = (_a = (await this.asyncExec(command, ["config", "list"])).stdout) === null || _a === void 0 ? void 0 : _a.trim();
            if ((0, builder_util_1.isEmptyOrSpaces)(config)) {
                builder_util_1.log.debug({ manager }, "unable to determine if node_modules are hoisted: no config output. falling back to hoisted mode");
                return false;
            }
            const lines = Object.fromEntries(config.split("\n").map(line => line.split("=").map(s => s.trim())));
            if (lines["node-linker"] === "hoisted") {
                builder_util_1.log.debug({ manager }, "node_modules are hoisted");
                return true;
            }
            return false;
        });
        this.appPkgJson = new lazy_val_1.Lazy(async () => {
            const appPkgPath = path.join(this.rootDir, "package.json");
            return this.readJsonMemoized(appPkgPath);
        });
    }
    async getNodeModules({ cancellationToken, packageName }) {
        const tree = await this.getDependenciesTree(this.installOptions.manager);
        if (cancellationToken.cancelled) {
            throw new Error("getNodeModules cancelled after fetching dependency tree");
        }
        await this.collectAllDependencies(tree, packageName);
        const realTree = await this.getTreeFromWorkspaces(tree, packageName);
        await this.extractProductionDependencyGraph(realTree, packageName);
        if (cancellationToken.cancelled) {
            throw new Error("getNodeModules cancelled after building production graph");
        }
        const hoisterResult = (0, hoist_1.hoist)(this.transformToHoisterTree(this.productionGraph, packageName), {
            check: builder_util_1.log.isDebugEnabled,
        });
        await this._getNodeModules(hoisterResult.dependencies, this.nodeModules);
        builder_util_1.log.debug({ packageName, depCount: this.nodeModules.length }, "node modules collection complete");
        return this.nodeModules;
    }
    async getDependenciesTree(pm) {
        const command = (0, packageManager_1.getPackageManagerCommand)(pm);
        const args = this.getArgs();
        const tempOutputFile = await this.tempDirManager.getTempFile({
            prefix: path.basename(command, path.extname(command)),
            suffix: "output.json",
        });
        return (0, builder_util_1.retry)(async () => {
            await this.streamCollectorCommandToFile(command, args, this.rootDir, tempOutputFile);
            const shellOutput = await fs.readFile(tempOutputFile, { encoding: "utf8" });
            return await this.parseDependenciesTree(shellOutput);
        }, {
            retries: 1,
            interval: 2000,
            backoff: 2000,
            shouldRetry: async (error) => {
                var _a;
                const logFields = { error: error.message, tempOutputFile, cwd: this.rootDir };
                if (!(await this.existsMemoized(tempOutputFile))) {
                    builder_util_1.log.debug(logFields, "dependency tree output file missing, retrying");
                    return true;
                }
                const fileContent = await fs.readFile(tempOutputFile, { encoding: "utf8" });
                const fields = { ...logFields, fileContent };
                if (fileContent.trim().length === 0) {
                    builder_util_1.log.debug(fields, "dependency tree output file empty, retrying");
                    return true;
                }
                if ((_a = error.message) === null || _a === void 0 ? void 0 : _a.includes("Unexpected end of JSON input")) {
                    builder_util_1.log.debug(fields, "JSON parse error in dependency tree, retrying");
                    return true;
                }
                builder_util_1.log.error(fields, "error parsing dependencies tree");
                return false;
            },
        });
    }
    async existsMemoized(filePath) {
        if (!this.cache.exists.has(filePath)) {
            this.cache.exists.set(filePath, await (0, builder_util_1.exists)(filePath));
        }
        return this.cache.exists.get(filePath);
    }
    async readJsonMemoized(filePath) {
        if (!this.cache.packageJson.has(filePath)) {
            this.cache.packageJson.set(filePath, await (0, fs_extra_1.readJson)(filePath));
        }
        return this.cache.packageJson.get(filePath);
    }
    async lstatMemoized(filePath) {
        if (!this.cache.lstat.has(filePath)) {
            this.cache.lstat.set(filePath, await fs.lstat(filePath));
        }
        return this.cache.lstat.get(filePath);
    }
    async realpathMemoized(filePath) {
        if (!this.cache.realPath.has(filePath)) {
            this.cache.realPath.set(filePath, await fs.realpath(filePath));
        }
        return this.cache.realPath.get(filePath);
    }
    requireMemoized(pkgPath) {
        if (!this.cache.packageJson.has(pkgPath)) {
            this.cache.packageJson.set(pkgPath, require(pkgPath));
        }
        return this.cache.packageJson.get(pkgPath);
    }
    existsSyncMemoized(filePath) {
        if (!this.cache.exists.has(filePath)) {
            this.cache.exists.set(filePath, fs.existsSync(filePath));
        }
        return this.cache.exists.get(filePath);
    }
    async resolvePath(filePath) {
        // Check if we've already resolved this path
        if (this.cache.realPath.has(filePath)) {
            return this.cache.realPath.get(filePath);
        }
        try {
            const stats = await this.lstatMemoized(filePath);
            if (stats.isSymbolicLink()) {
                const resolved = await this.realpathMemoized(filePath);
                this.cache.realPath.set(filePath, resolved);
                return resolved;
            }
            else {
                this.cache.realPath.set(filePath, filePath);
                return filePath;
            }
        }
        catch (error) {
            builder_util_1.log.debug({ filePath, message: error.message || error.stack }, "error resolving path");
            this.cache.realPath.set(filePath, filePath);
            return filePath;
        }
    }
    /**
     * Resolve a package directory purely from the filesystem.
     * Does NOT attempt to load the module or resolve an "exports" entrypoint.
     * Good for Yarn 4 because a package may not be resolvable as a module,
     * but still exists on disk.
     */
    async resolvePackage(packageName, fromDir) {
        const cacheKey = `${packageName}::${fromDir}`;
        if (this.cache.requireResolve.has(cacheKey)) {
            return this.cache.requireResolve.get(cacheKey);
        }
        // 1. NESTED under fromDir/node_modules/<name>
        let candidate = path.join(fromDir, "node_modules", packageName);
        let pkgJson = path.join(candidate, "package.json");
        if (await this.existsMemoized(pkgJson)) {
            this.cache.requireResolve.set(cacheKey, { entry: pkgJson, packageDir: candidate });
            return { entry: pkgJson, packageDir: candidate };
        }
        // 2. HOISTED under rootDir/node_modules/<name>
        candidate = path.join(this.rootDir, "node_modules", packageName);
        pkgJson = path.join(candidate, "package.json");
        if (await this.existsMemoized(pkgJson)) {
            this.cache.requireResolve.set(cacheKey, { entry: pkgJson, packageDir: candidate });
            return { entry: pkgJson, packageDir: candidate };
        }
        // 3. FALLBACK: try parent directories BFS (classic Node-style search)
        let current = fromDir;
        while (true) {
            const nm = path.join(current, "node_modules", packageName);
            const pkg = path.join(nm, "package.json");
            if (await this.existsMemoized(pkg)) {
                this.cache.requireResolve.set(cacheKey, { entry: pkg, packageDir: nm });
                return { entry: pkg, packageDir: nm };
            }
            const parent = path.dirname(current);
            if (parent === current) {
                break;
            }
            current = parent;
        }
        // 4. LAST RESORT: DO NOT throw — just return null
        this.cache.requireResolve.set(cacheKey, null);
        return null;
    }
    cacheKey(pkg) {
        const rel = path.relative(this.rootDir, pkg.path);
        return `${pkg.name}::${pkg.version}::${rel !== null && rel !== void 0 ? rel : "."}`;
    }
    packageVersionString(pkg) {
        return `${pkg.name}@${pkg.version}`;
    }
    /**
     * Parse a dependency identifier like "@scope/pkg@1.2.3" or "pkg@1.2.3"
     */
    parseNameVersion(identifier) {
        const lastAt = identifier.lastIndexOf("@");
        if (lastAt <= 0) {
            // fallback for scoped packages or malformed strings
            return { name: identifier, version: "unknown" };
        }
        const name = identifier.slice(0, lastAt);
        const version = identifier.slice(lastAt + 1);
        return { name, version };
    }
    async getTreeFromWorkspaces(tree, packageName) {
        var _a;
        if (!(tree.workspaces && tree.dependencies)) {
            return tree;
        }
        if ((_a = tree.dependencies) === null || _a === void 0 ? void 0 : _a[packageName]) {
            const { name, path, dependencies } = tree.dependencies[packageName];
            builder_util_1.log.debug({ name, path, dependencies: JSON.stringify(dependencies) }, "pruning root app/self reference from workspace tree, merging dependencies uptree");
            for (const [name, pkg] of Object.entries(dependencies !== null && dependencies !== void 0 ? dependencies : {})) {
                tree.dependencies[name] = pkg;
                this.allDependencies.set(this.packageVersionString(pkg), pkg);
            }
            delete tree.dependencies[packageName];
        }
        return Promise.resolve(tree);
    }
    transformToHoisterTree(obj, key, nodes = new Map()) {
        let node = nodes.get(key);
        const { name, version } = this.parseNameVersion(key);
        if (!node) {
            node = {
                name,
                identName: name,
                reference: version,
                dependencies: new Set(),
                peerNames: new Set(),
            };
            nodes.set(key, node);
            const deps = (obj[key] || {}).dependencies || [];
            for (const dep of deps) {
                const child = this.transformToHoisterTree(obj, dep, nodes);
                node.dependencies.add(child);
            }
        }
        return node;
    }
    async _getNodeModules(dependencies, result) {
        var _a;
        if (dependencies.size === 0) {
            return;
        }
        for (const d of dependencies.values()) {
            const reference = [...d.references][0];
            const p = (_a = this.allDependencies.get(`${d.name}@${reference}`)) === null || _a === void 0 ? void 0 : _a.path;
            if (p === undefined) {
                builder_util_1.log.debug({ name: d.name, reference }, "cannot find path for dependency");
                continue;
            }
            // fix npm list issue
            // https://github.com/npm/cli/issues/8535
            if (!(await (0, builder_util_1.exists)(p))) {
                builder_util_1.log.debug({ name: d.name, reference, p }, "dependency path does not exist");
                continue;
            }
            const node = {
                name: d.name,
                version: reference,
                dir: await this.resolvePath(p),
            };
            result.push(node);
            if (d.dependencies.size > 0) {
                node.dependencies = [];
                await this._getNodeModules(d.dependencies, node.dependencies);
            }
        }
        result.sort((a, b) => a.name.localeCompare(b.name));
    }
    async asyncExec(command, args, cwd = this.rootDir) {
        const file = await this.tempDirManager.getTempFile({ prefix: "exec-", suffix: ".txt" });
        try {
            await this.streamCollectorCommandToFile(command, args, cwd, file);
            const result = await fs.readFile(file, { encoding: "utf8" });
            return { stdout: result === null || result === void 0 ? void 0 : result.trim(), stderr: undefined };
        }
        catch (error) {
            builder_util_1.log.debug({ error: error.message }, "failed to execute command");
            return { stdout: undefined, stderr: error.message };
        }
    }
    async streamCollectorCommandToFile(command, args, cwd, tempOutputFile) {
        const execName = path.basename(command, path.extname(command));
        const isWindowsScriptFile = process.platform === "win32" && path.extname(command).toLowerCase() === ".cmd";
        if (isWindowsScriptFile) {
            // If the command is a Windows script file (.cmd), we need to wrap it in a .bat file to ensure it runs correctly with cmd.exe
            // This is necessary because .cmd files are not directly executable in the same way as .bat files.
            // We create a temporary .bat file that calls the .cmd file with the provided arguments. The .bat file will be executed by cmd.exe.
            // Note: This is a workaround for Windows command execution quirks for specifically when `shell: false`
            const tempBatFile = await this.tempDirManager.getTempFile({
                prefix: execName,
                suffix: ".bat",
            });
            const batScript = `@echo off\r\n"${command}" %*\r\n`; // <-- CRLF required for .bat
            await fs.writeFile(tempBatFile, batScript, { encoding: "utf8" });
            command = "cmd.exe";
            args = ["/c", tempBatFile, ...args];
        }
        builder_util_1.log.debug({ command, args, cwd, tempOutputFile }, "spawning node module collector process");
        await new Promise((resolve, reject) => {
            const outStream = (0, fs_extra_1.createWriteStream)(tempOutputFile);
            const child = childProcess.spawn(command, args, {
                cwd,
                shell: false, // required to prevent console logs polution from shell profile loading when `true`
            });
            let stderr = "";
            child.stdout.pipe(outStream);
            child.stderr.on("data", chunk => {
                stderr += chunk.toString();
            });
            child.on("error", err => {
                reject(new Error(`Node module collector spawn failed: ${err.message}`));
            });
            child.on("close", code => {
                outStream.close();
                // https://github.com/npm/npm/issues/17624
                const shouldIgnore = code === 1 && "npm" === execName.toLowerCase() && args.includes("list");
                if (shouldIgnore) {
                    builder_util_1.log.debug(null, "`npm list` returned non-zero exit code, but it MIGHT be expected (https://github.com/npm/npm/issues/17624). Check stderr for details.");
                }
                if (stderr.length > 0) {
                    builder_util_1.log.debug({ stderr }, "note: there was node module collector output on stderr");
                }
                const shouldResolve = code === 0 || shouldIgnore;
                return shouldResolve ? resolve() : reject(new Error(`Node module collector process exited with code ${code}:\n${stderr}`));
            });
        });
    }
}
exports.NodeModulesCollector = NodeModulesCollector;
//# sourceMappingURL=nodeModulesCollector.js.map