/**
 * Copyright JS Foundation and other contributors, http://js.foundation
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 **/

var crypto = require('crypto');
var runtime;
var settings;
var log;


var encryptedCredentials = null;
var credentialCache = {};
var credentialsDef = {};
var dirty = false;

var removeDefaultKey = false;
var encryptionEnabled = null;
var encryptionKeyType; // disabled, system, user, project
var encryptionAlgorithm = "aes-256-ctr";
var encryptionKey;

function decryptCredentials(key,credentials) {
    var creds = credentials["$"];
    var initVector = Buffer.from(creds.substring(0, 32),'hex');
    creds = creds.substring(32);
    var decipher = crypto.createDecipheriv(encryptionAlgorithm, key, initVector);
    var decrypted = decipher.update(creds, 'base64', 'utf8') + decipher.final('utf8');
    return JSON.parse(decrypted);
}
function encryptCredentials(key,credentials) {
    var initVector = crypto.randomBytes(16);
    var cipher = crypto.createCipheriv(encryptionAlgorithm, key, initVector);
    return {"$":initVector.toString('hex') + cipher.update(JSON.stringify(credentials), 'utf8', 'base64') + cipher.final('base64')};
}

var api = module.exports = {
    init: function(_runtime) {
        runtime = _runtime;
        log = runtime.log;
        settings = runtime.settings;
        dirty = false;
        credentialCache = {};
        credentialsDef = {};
        encryptionEnabled = null;
    },

    /**
     * Sets the credentials from storage.
     */
    load: async function (credentials) {
        dirty = false;
        var credentialsEncrypted = credentials.hasOwnProperty("$") && Object.keys(credentials).length === 1;

        // Case 1: Active Project in place
        //  - use whatever its config says

        // Case 2: _credentialSecret unset, credentialSecret unset
        //  - generate _credentialSecret and encrypt

        // Case 3: _credentialSecret set, credentialSecret set
        //  - migrate from _credentialSecret to credentialSecret
        //  - delete _credentialSecret

        // Case 4: credentialSecret set
        //  - use it

        var setupEncryptionPromise = Promise.resolve();

        var projectKey = false;
        var activeProject;
        encryptionKeyType = "";

        if (runtime.storage && runtime.storage.projects) {
            // projects enabled
            activeProject = runtime.storage.projects.getActiveProject();
            if (activeProject) {
                projectKey = activeProject.credentialSecret;
                if (!projectKey) {
                    log.debug("red/runtime/nodes/credentials.load : using active project key - disabled");
                    encryptionKeyType = "disabled";
                    encryptionEnabled = false;
                } else {
                    log.debug("red/runtime/nodes/credentials.load : using active project key");
                    encryptionKeyType = "project";
                    encryptionKey = crypto.createHash('sha256').update(projectKey).digest();
                    encryptionEnabled = true;
                }
            }
        }

        if (encryptionKeyType === '') {
            var defaultKey;
            try {
                defaultKey = settings.get('_credentialSecret');
            } catch(err) {
            }
            if (defaultKey) {
                defaultKey = crypto.createHash('sha256').update(defaultKey).digest();
                encryptionKeyType = "system";
            }
            var userKey;
            try {
                userKey = settings.get('credentialSecret');
            } catch(err) {
                userKey = false;
            }

            if (userKey === false) {
                encryptionKeyType = "disabled";
                log.debug("red/runtime/nodes/credentials.load : user disabled encryption");
                // User has disabled encryption
                encryptionEnabled = false;
                // Check if we have a generated _credSecret to decrypt with and remove
                if (defaultKey) {
                    log.debug("red/runtime/nodes/credentials.load : default key present. Will migrate");
                    if (credentialsEncrypted) {
                        try {
                            credentials = decryptCredentials(defaultKey,credentials)
                        } catch(err) {
                            credentials = {};
                            log.warn(log._("nodes.credentials.error",{message:err.toString()}))
                            var error = new Error("Failed to decrypt credentials");
                            error.code = "credentials_load_failed";
                            throw error;
                        }
                    }
                    dirty = true;
                    removeDefaultKey = true;
                }
            } else if (typeof userKey === 'string') {
                if (!projectKey) {
                    log.debug("red/runtime/nodes/credentials.load : user provided key");
                }
                if (encryptionKeyType !== 'project') {
                    encryptionKeyType = 'user';
                }
                // User has provided own encryption key, get the 32-byte hash of it
                encryptionKey = crypto.createHash('sha256').update(userKey).digest();
                encryptionEnabled = true;

                if (encryptionKeyType !== 'project' && defaultKey) {
                    log.debug("red/runtime/nodes/credentials.load : default key present. Will migrate");
                    // User has provided their own key, but we already have a default key
                    // Decrypt using default key
                    if (credentialsEncrypted) {
                        try {
                            credentials = decryptCredentials(defaultKey,credentials)
                        } catch(err) {
                            credentials = {};
                            log.warn(log._("nodes.credentials.error",{message:err.toString()}))
                            var error = new Error("Failed to decrypt credentials");
                            error.code = "credentials_load_failed";
                            throw error;
                        }
                    }
                    dirty = true;
                    removeDefaultKey = true;
                }
            } else {
                log.debug("red/runtime/nodes/credentials.load : no user key present");
                // User has not provide their own key
                if (encryptionKeyType !== 'project') {
                    encryptionKeyType = 'system';
                }
                encryptionKey = defaultKey;
                encryptionEnabled = true;
                if (encryptionKey === undefined) {
                    log.debug("red/runtime/nodes/credentials.load : no default key present - generating one");
                    // No user-provided key, no generated key
                    // Generate a new key
                    defaultKey = crypto.randomBytes(32).toString('hex');
                    try {
                        setupEncryptionPromise = settings.set('_credentialSecret',defaultKey);
                        encryptionKey = crypto.createHash('sha256').update(defaultKey).digest();
                    } catch(err) {
                        log.debug("red/runtime/nodes/credentials.load : settings unavailable - disabling encryption");
                        // Settings unavailable
                        encryptionEnabled = false;
                        encryptionKey = null;
                    }
                    dirty = true;
                } else {
                    log.debug("red/runtime/nodes/credentials.load : using default key");
                }
            }
        }

        if (encryptionEnabled && !dirty) {
            encryptedCredentials = credentials;
        }
        log.debug("red/runtime/nodes/credentials.load : keyType="+encryptionKeyType);
        if (encryptionKeyType === 'system') {
            log.warn(log._("nodes.credentials.system-key-warning"));
        }
        return setupEncryptionPromise.then(function() {
            var clearInvalidFlag = false;
            if (credentials.hasOwnProperty("$")) {
                if (encryptionEnabled === false) {
                    // The credentials appear to be encrypted, but our config
                    //  thinks they are not.
                    var error = new Error("Failed to decrypt credentials");
                    error.code = "credentials_load_failed";
                    if (activeProject) {
                        // This is a project with a bad key. Mark it as invalid
                        // TODO: this delves too deep into Project structure
                        activeProject.credentialSecretInvalid = true;
                        throw error;
                    }
                    throw error;
                }
                // These are encrypted credentials
                try {
                    credentialCache = decryptCredentials(encryptionKey,credentials)
                    clearInvalidFlag = true;
                } catch(err) {
                    credentialCache = {};
                    dirty = true;
                    log.warn(log._("nodes.credentials.error",{message:err.toString()}))
                    var error = new Error("Failed to decrypt credentials");
                    error.code = "credentials_load_failed";
                    if (activeProject) {
                        // This is a project with a bad key. Mark it as invalid
                        // TODO: this delves too deep into Project structure
                        activeProject.credentialSecretInvalid = true;
                        throw error;
                    }
                    throw error;
                }
            } else {
                if (encryptionEnabled) {
                    // Our config expects the credentials to be encrypted but the encrypted object is not found
                    log.warn(log._("nodes.credentials.encryptedNotFound"))
                    credentialCache = credentials;
                } else {
                    // credentialSecret is set to False
                    log.warn(log._("nodes.credentials.unencrypted"))
                    credentialCache = credentials;
                }
            }
            if (clearInvalidFlag) {
                // TODO: this delves too deep into Project structure
                if (activeProject) {
                    delete activeProject.credentialSecretInvalid;
                }
            }
        });
    },

    /**
     * Adds a set of credentials for the given node id.
     * @param id the node id for the credentials
     * @param creds an object of credential key/value pairs
     * @return a promise for backwards compatibility TODO: can this be removed?
     */
    add: async function (id, creds) {
        if (!credentialCache.hasOwnProperty(id) || JSON.stringify(creds) !== JSON.stringify(credentialCache[id])) {
            credentialCache[id] = creds;
            dirty = true;
        }
    },

    /**
     * Gets the credentials for the given node id.
     * @param id the node id for the credentials
     * @return the credentials
     */
    get: function (id) {
        return credentialCache[id];
    },

    /**
     * Deletes the credentials for the given node id.
     * @param id the node id for the credentials
     * @return a promise for the saving of credentials to storage
     */
    delete: function (id) {
        delete credentialCache[id];
        dirty = true;
    },

    clear: function() {
        credentialCache = {};
        dirty = true;
    },
    /**
     * Deletes any credentials for nodes that no longer exist
     * @param config a flow config
     * @return a promise for the saving of credentials to storage
     */
    clean: async function (config) {
        var existingIds = {};
        config.forEach(function(n) {
            existingIds[n.id] = true;
            if (n.credentials) {
                api.extract(n);
            }
        });
        var deletedCredentials = false;
        for (var c in credentialCache) {
            if (credentialCache.hasOwnProperty(c)) {
                if (!existingIds[c]) {
                    deletedCredentials = true;
                    delete credentialCache[c];
                }
            }
        }
        if (deletedCredentials) {
            dirty = true;
        }
    },

    /**
     * Registers a node credential definition.
     * @param type the node type
     * @param definition the credential definition
     */
    register: function (type, definition) {
        var dashedType = type.replace(/\s+/g, '-');
        credentialsDef[dashedType] = definition;
    },

    /**
     * Extracts and stores any credential updates in the provided node.
     * The provided node may have a .credentials property that contains
     * new credentials for the node.
     * This function loops through the credentials in the definition for
     * the node-type and applies any of the updates provided in the node.
     *
     * This function does not save the credentials to disk as it is expected
     * to be called multiple times when a new flow is deployed.
     *
     * @param node the node to extract credentials from
     */
    extract: function(node) {
        var nodeID = node.id;
        var nodeType = node.type;
        var cred;
        var newCreds = node.credentials;
        if (newCreds) {
            delete node.credentials;
            var savedCredentials = credentialCache[nodeID] || {};
            // Need to check the type of constructor for this node.
            // - Function : regular node
            // - !Function: subflow module

            if (/^subflow(:|$)/.test(nodeType) || typeof runtime.nodes.getType(nodeType) !== 'function') {
                for (cred in newCreds) {
                    if (newCreds.hasOwnProperty(cred)) {
                        if (newCreds[cred] === "__PWRD__") {
                            continue;
                        }
                        if (0 === newCreds[cred].length || /^\s*$/.test(newCreds[cred])) {
                            delete savedCredentials[cred];
                            dirty = true;
                            continue;
                        }
                        if (!savedCredentials.hasOwnProperty(cred) || JSON.stringify(savedCredentials[cred]) !== JSON.stringify(newCreds[cred])) {
                            savedCredentials[cred] = newCreds[cred];
                            dirty = true;
                        }

                    }
                }
                if (/^subflow(:|$)/.test(nodeType)) {
                    for (cred in savedCredentials) {
                        if (savedCredentials.hasOwnProperty(cred)) {
                            if (!newCreds.hasOwnProperty(cred)) {
                                delete savedCredentials[cred];
                                dirty = true;
                            }
                        }
                    }
                }
            } else if (nodeType === "global-config") {
                savedCredentials.map = savedCredentials.map || {}
                const existingCredentialKeys = Object.keys(savedCredentials.map)
                const newCredentialKeys = Object.keys(newCreds?.map || [])
                existingCredentialKeys.forEach(key => {
                    if (!newCreds.map?.[key]) {
                        // This key doesn't exist in the new credentials list - remove
                        delete savedCredentials.map[key]
                        delete savedCredentials.map[`has_${key}`]
                        dirty = true
                    }
                })
                newCredentialKeys.forEach(key => {
                    if (!/^has_/.test(key)) {
                        if (!savedCredentials.map[key] || newCreds.map[key] !== '__PWRD__') {
                            // This key either doesn't exist in current saved, or the
                            // value has been changed
                            savedCredentials.map[key] = newCreds.map[key]
                            savedCredentials.map[`has_${key}`] = newCreds.map[`has_${key}`]
                            dirty = true
                        }
                    }
                })
            } else {
                var dashedType = nodeType.replace(/\s+/g, '-');
                var definition = credentialsDef[dashedType];
                if (!definition) {
                    log.warn(log._("nodes.credentials.not-registered",{type:nodeType}));
                    return;
                }

                for (cred in definition) {
                    if (definition.hasOwnProperty(cred)) {
                        if (newCreds[cred] === undefined) {
                            continue;
                        }
                        if (definition[cred].type == "password" && newCreds[cred] == '__PWRD__') {
                            continue;
                        }
                        if (0 === newCreds[cred].length || /^\s*$/.test(newCreds[cred])) {
                            delete savedCredentials[cred];
                            dirty = true;
                            continue;
                        }
                        if (!savedCredentials.hasOwnProperty(cred) || JSON.stringify(savedCredentials[cred]) !== JSON.stringify(newCreds[cred])) {
                            savedCredentials[cred] = newCreds[cred];
                            dirty = true;
                        }
                    }
                }
            }
            credentialCache[nodeID] = savedCredentials;
        }
    },

    /**
     * Gets the credential definition for the given node type
     * @param type the node type
     * @return the credential definition
     */
    getDefinition: function (type) {
        return credentialsDef[type];
    },

    dirty: function() {
        return dirty;
    },
    setKey: function(key) {
        if (key) {
            encryptionKey = crypto.createHash('sha256').update(key).digest();
            encryptionEnabled = true;
            dirty = true;
            encryptionKeyType = "project";
        } else {
            encryptionKey = null;
            encryptionEnabled = false;
            dirty = true;
            encryptionKeyType = "disabled";
        }
    },
    getKeyType: function() {
        return encryptionKeyType;
    },
    export: async function() {
        var result = credentialCache;

        if (encryptionEnabled) {
            if (dirty) {
                try {
                    log.debug("red/runtime/nodes/credentials.export : encrypting");
                    result = encryptCredentials(encryptionKey, credentialCache);
                } catch(err) {
                    log.warn(log._("nodes.credentials.error-saving",{message:err.toString()}))
                }
            } else {
                result = encryptedCredentials;
            }
        }
        dirty = false;
        if (removeDefaultKey) {
            log.debug("red/runtime/nodes/credentials.export : removing unused default key");
            return settings.delete('_credentialSecret').then(function() {
                removeDefaultKey = false;
                return result;
            })
        } else {
            return result;
        }
    }
}
