// This module handles the management of modules required by the runtime and flows.
// Essentially this means keeping track of what extra modules a flow requires,
// ensuring those modules are installed and providing a standard way for nodes
// to require those modules safely.

const fs = require("fs-extra");
const registryUtil = require("./util");
const path = require("path");
const clone = require("clone");
const exec = require("@node-red/util").exec;
const log = require("@node-red/util").log;
const hooks = require("@node-red/util").hooks;
const url = require("url");
const { createRequire } = require("module");

const BUILTIN_MODULES = require('module').builtinModules;

// TODO: outsource running npm to a plugin
const NPM_COMMAND = (process.platform === "win32") ? "npm.cmd" : "npm";

let registeredTypes = {};
let subflowTypes = {};
let settings;

let knownExternalModules = {};

let installEnabled = true;
let installAllowList = ['*'];
let installDenyList = [];

let IMPORT_SUPPORTED = true;
const nodeVersionParts = process.versions.node.split(".").map(v => parseInt(v));
if (nodeVersionParts[0] < 12 || (nodeVersionParts[0] === 12 && nodeVersionParts[1] < 17)) {
    IMPORT_SUPPORTED = false;
}

function getInstallDir() {
    return path.resolve(settings.userDir || process.env.NODE_RED_HOME || ".");
}

let loggedLegacyWarning = false;
async function refreshExternalModules() {

    if (!loggedLegacyWarning) {
        loggedLegacyWarning = true;
        const oldExternalModulesDir = path.join(path.resolve(settings.userDir || process.env.NODE_RED_HOME || "."),"externalModules");
        if (fs.existsSync(oldExternalModulesDir)) {
            try {
                log.warn(log._("server.install.old-ext-mod-dir-warning",{oldDir:oldExternalModulesDir, newDir:getInstallDir()}))
            } catch(err) {console.log(err)}
        }

    }


    const externalModuleDir = getInstallDir();
    try {
        const pkgFile = JSON.parse(await fs.readFile(path.join(externalModuleDir,"package.json"),"utf-8"));
        knownExternalModules = pkgFile.dependencies || {};
    } catch(err) {
        knownExternalModules = {};
    }
}

function init(_settings) {
    settings = _settings;
    knownExternalModules = {};
    installEnabled = true;

    if (settings.externalModules && settings.externalModules.modules) {
        if (settings.externalModules.modules.allowList || settings.externalModules.modules.denyList) {
            installAllowList = settings.externalModules.modules.allowList;
            installDenyList = settings.externalModules.modules.denyList;
        }
        if (settings.externalModules.modules.hasOwnProperty("allowInstall")) {
            installEnabled = settings.externalModules.modules.allowInstall
        }
    }
    installAllowList = registryUtil.parseModuleList(installAllowList);
    installDenyList = registryUtil.parseModuleList(installDenyList);
}

function register(type, dynamicModuleListProperty) {
    registeredTypes[type] = dynamicModuleListProperty;
}

function registerSubflow(type, subflowConfig) {
    subflowTypes[type] = subflowConfig;
}

function requireModule(module) {
    if (!registryUtil.checkModuleAllowed( module, null,installAllowList,installDenyList)) {
        const e = new Error("Module not allowed");
        e.code = "module_not_allowed";
        throw e;
    }

    const parsedModule = parseModuleName(module);

    if (BUILTIN_MODULES.indexOf(parsedModule.module) !== -1) {
        return require(parsedModule.module + parsedModule.subpath);
    }
    if (!knownExternalModules[parsedModule.module]) {
        const e = new Error("Module not allowed");
        e.code = "module_not_allowed";
        throw e;
    }
    const externalModuleDir = getInstallDir();
    const moduleDir = path.join(externalModuleDir,"node_modules",module);
    return require(moduleDir);
}
function importModule(module) {
    if (!IMPORT_SUPPORTED) {
        // On Node < 12.17 - fall back to try a require
        return new Promise((resolve, reject) => {
            try {
                const mod = requireModule(module);
                resolve(mod);
            } catch(err) {
                reject(err);
            }
        });
    }

    if (!registryUtil.checkModuleAllowed( module, null,installAllowList,installDenyList)) {
        const e = new Error("Module not allowed");
        e.code = "module_not_allowed";
        throw e;
    }

    const parsedModule = parseModuleName(module);

    if (BUILTIN_MODULES.indexOf(parsedModule.module) !== -1) {
        return import(parsedModule.module + parsedModule.subpath);
    }
    if (!knownExternalModules[parsedModule.module]) {
        const e = new Error("Module not allowed");
        e.code = "module_not_allowed";
        throw e;
    }
    const externalModuleDir = getInstallDir();
    const moduleDir = path.join(externalModuleDir,"node_modules",module);
    // To handle both CJS and ESM we need to resolve the module to the
    // specific file that is loaded when the module is required/imported
    // As this won't be on the natural module search path, we use createRequire
    // to access the module
    const modulePath = createRequire(moduleDir).resolve(module)
    // Import needs the full path to the module's main .js file
    // It also needs to be a file:// url for Windows
    const moduleUrl = url.pathToFileURL(modulePath);
    return import(moduleUrl);
}

function parseModuleName(module) {
    var match = /((?:@[^/]+\/)?[^/@]+)(\/[^/@]+)?(?:@([\s\S]+))?/.exec(module);
    if (match) {
        return {
            spec: module,
            module: match[1],
            subpath: match[2] || '',
            version: match[3],
            builtin: BUILTIN_MODULES.indexOf(match[1]) !== -1,
            known: !!knownExternalModules[match[1]]
        }
    }
    return null;
}

function isInstalled(moduleDetails) {
    return moduleDetails.builtin || moduleDetails.known;
}


async function checkFlowDependencies(flowConfig) {
    let nodes = clone(flowConfig);
    await refreshExternalModules();

    const checkedModules = {};
    const promises = [];
    const errors = [];
    const checkedSubflows = {};
    while (nodes.length > 0) {
        let n = nodes.shift();
        if (subflowTypes[n.type] && !checkedSubflows[n.type]) {
            checkedSubflows[n.type] = true;
            nodes = nodes.concat(subflowTypes[n.type].flow)
        } else if (registeredTypes[n.type]) {
            let nodeModules = n[registeredTypes[n.type]] || [];
            if (!Array.isArray(nodeModules)) {
                nodeModules = [nodeModules]
            }
            nodeModules.forEach(module => {
                if (typeof module !== 'string') {
                    module = module.module || "";
                }
                if (module) {
                    let moduleDetails = parseModuleName(module)
                    if (moduleDetails && checkedModules[moduleDetails.module] === undefined) {
                        checkedModules[moduleDetails.module] = isInstalled(moduleDetails)
                        if (!checkedModules[moduleDetails.module]) {
                            if (installEnabled) {
                                promises.push(installModule(moduleDetails).catch(err => {
                                    errors.push({module: moduleDetails,error:err});
                                }))
                            } else if (!installEnabled) {
                                const e = new Error("Module install disabled - externalModules.modules.allowInstall=false");
                                e.code = "install_not_allowed";
                                errors.push({module: moduleDetails,error:e});
                            }
                        } else if (!registryUtil.checkModuleAllowed( moduleDetails.module, moduleDetails.version,installAllowList,installDenyList)) {
                            const e = new Error("Module not allowed");
                            e.code = "module_not_allowed";
                            errors.push({module: moduleDetails,error:e});
                        }
                    }
                }
            })
        }
    }
    return Promise.all(promises).then(refreshExternalModules).then(() => {
        if (errors.length > 0) {
            throw errors;
        }
    })
}


async function ensureModuleDir() {
    const installDir = getInstallDir();

    if (!fs.existsSync(installDir)) {
        await fs.ensureDir(installDir);
    }
    const pkgFile = path.join(installDir,"package.json");
    if (!fs.existsSync(pkgFile)) {
        await fs.writeFile(path.join(installDir,"package.json"),`{
    "name": "Node-RED-External-Modules",
    "description": "These modules are automatically installed by Node-RED to use in Function nodes.",
    "version": "1.0.0",
    "private": true,
    "dependencies": {}
}`)
    }
}

let installLock = Promise.resolve()
async function installModule(moduleDetails) {
    const result = installLock.then(async () => {
        let installSpec = moduleDetails.module;
        if (!registryUtil.checkModuleAllowed( moduleDetails.module, moduleDetails.version,installAllowList,installDenyList)) {
            const e = new Error("Install not allowed");
            e.code = "install_not_allowed";
            throw e;
        }
        if (moduleDetails.version) {
            installSpec = installSpec+"@"+moduleDetails.version;
        }
        log.info(log._("server.install.installing",{name: moduleDetails.module,version: moduleDetails.version||"latest"}));
        const installDir = getInstallDir();

        await ensureModuleDir();

        let triggerPayload = {
            "module": moduleDetails.module,
            "version": moduleDetails.version,
            "dir": installDir,
            "args": ["--production","--engine-strict"]
        }
        return hooks.trigger("preInstall", triggerPayload).then((result) => {
            // preInstall passed
            // - run install
            if (result !== false) {
                let extraArgs = triggerPayload.args || [];
                let args = ['install', ...extraArgs, installSpec]
                log.trace(NPM_COMMAND + JSON.stringify(args));
                return exec.run(NPM_COMMAND, args, { cwd: installDir },true)
            } else {
                log.trace("skipping npm install");
            }
        }).then(() => {
            return hooks.trigger("postInstall", triggerPayload)
        }).then(() => {
            log.info(log._("server.install.installed", { name: installSpec }));
            const runtimeInstalledModules = settings.get("modules") || {};
            runtimeInstalledModules[moduleDetails.module] = moduleDetails;
            settings.set("modules",runtimeInstalledModules)
            log.audit({event: "modules.install",module:moduleDetails.module, version:moduleDetails.version});
        }).catch(result => {
            var output = result.stderr || result.toString();
            var e;
            if (/E404/.test(output) || /ETARGET/.test(output)) {
                log.error(log._("server.install.install-failed-not-found",{name:installSpec}));
                e = new Error("Module not found");
                e.code = 404;
                throw e;
            } else {
                log.error(log._("server.install.install-failed-long",{name:installSpec}));
                log.error("------------------------------------------");
                log.error(output);
                log.error("------------------------------------------");
                e = new Error(log._("server.install.install-failed"));
                e.code = "unexpected_error";
                throw e;
            }
        })
    })
    installLock = result.catch(() => {})
    return result
}

module.exports = {
    init,
    register,
    registerSubflow,
    checkFlowDependencies,
    require: requireModule,
    import: importModule
}
