// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later

const { existsSync, readdirSync }  = require('node:fs');

const Crypto = require('crypto');
const WriteQueue = require("./write-queue");
const BatchRead = require("./batch-read");

const Keys = require("./keys");
const Core = require("./commands/core");

const Quota = require("./commands/quota");
const Util = require("./common-util");
const Package = require("../package.json");
const Default = require("./defaults");
const Path = require("path");

const plugins = require('./plugin-manager');

const Nacl = require("tweetnacl/nacl-fast");

var canonicalizeOrigin = function (s) {
    if (typeof(s) === 'undefined') { return; }
    return (s || '').trim().replace(/\/+$/, '');
};

var isValidPort = function (p) {
    return typeof(p) === 'number' && p < 65535;
};

var deriveSandboxOrigin = function (unsafe, port) {
    var url = new URL(unsafe);
    url.port = port;
    return url.origin;
};

var isRecentVersion = function () {
    var R = Default.recommendedVersion;
    var V = process.version;
    if (typeof(V) !== 'string') { return false; }
    var parts = V.replace(/^v/, '').split('.').map(Number);
    if (parts.length < 3) { return false; }
    if (!parts.every(n => typeof(n) === 'number' && !isNaN(n))) {
        return false;
    }
    if (parts[0] < R[0]) { return false; }
    if (parts[0] > R[0]) { return true; }

    // v16
    if (parts[1] < R[1]) { return false; }
    if (parts[1] > R[1]) { return true; }
    if (parts[2] >= R[2]) { return true; }

    return false;
};

const getInstalledOOVersions = function() {
    if (!existsSync('www/common/onlyoffice/dist')) {
        return [];
    }

    return readdirSync('www/common/onlyoffice/dist');
};

module.exports.create = function (config) {
    var httpUnsafeOrigin = canonicalizeOrigin(config.httpUnsafeOrigin);

    var httpSafeOrigin;
    var NO_SANDBOX = false;
    var httpSafePort;
    var httpPort = isValidPort(config.httpPort)? config.httpPort: 3000;

    if (typeof(config.httpSafeOrigin) !== 'string') {
        NO_SANDBOX = true;
        if (typeof(config.httpSafePort) !== 'number') { httpSafePort = httpPort + 1; }
        httpSafeOrigin = deriveSandboxOrigin(httpUnsafeOrigin, httpSafePort);
    } else {
        httpSafeOrigin = canonicalizeOrigin(config.httpSafeOrigin);
    }

    if (typeof(config.websocketPort) !== 'number') {
        config.websocketPort = 3003;
    }

    var permittedEmbedders = config.permittedEmbedders;
    if (typeof(permittedEmbedders) === 'string') {
        permittedEmbedders = permittedEmbedders.trim();
    }

    const curve = Nacl.box.keyPair();

    const Env = {
        plugins: plugins,
        logFeedback: Boolean(config.logFeedback),
        mainPages: config.mainPages || Default.mainPages(),

        protocol: new URL(httpUnsafeOrigin).protocol,

        fileHost: config.fileHost || undefined,
        NO_SANDBOX: NO_SANDBOX,
        httpSafePort: httpSafePort,
        websocketPort: config.websocketPort,

        accounts_api: config.accounts_api,

        shouldUpdateNode: !isRecentVersion(),

        version: Package.version,
        installMethod: config.installMethod || undefined,

        httpUnsafeOrigin: httpUnsafeOrigin,
        httpSafeOrigin: httpSafeOrigin,
        permittedEmbedders: typeof(permittedEmbedders) === 'string' && permittedEmbedders? permittedEmbedders: httpSafeOrigin,

        removeDonateButton: config.removeDonateButton,
        httpPort: isValidPort(config.httpPort)? config.httpPort: 3000,
        httpAddress: typeof(config.httpAddress) === 'string'? config.httpAddress: 'localhost',
        websocketPath: config.externalWebsocketURL,
        logIP: config.logIP,

        OFFLINE_MODE: false,
        FRESH_KEY: '',
        FRESH_MODE: true,
        DEV_MODE: false,
        configCache: {},
        broadcastCache: {},

        officeHeadersCache: undefined,
        standardHeadersCache: undefined,
        apiHeadersCache: undefined,

        flushCache: function () {
            Env.FRESH_KEY = +new Date();
            if (!(Env.DEV_MODE || Env.FRESH_MODE)) { Env.FRESH_MODE = true; }
            Env.cacheFlushed.fire();
            if (!Env.Log) { return; }
            Env.Log.info("UPDATING_FRESH_KEY", Env.FRESH_KEY);
        },

        Log: undefined,
        // store
        id: Crypto.randomBytes(8).toString('hex'),

        launchTime: +new Date(),

        enableProfiling: false,
        profilingWindow: 10000,
        bytesWritten: 0,

        inactiveTime: config.inactiveTime,
        archiveRetentionTime: config.archiveRetentionTime,
        accountRetentionTime: config.accountRetentionTime,

        adminEmail: config.adminEmail,
        supportMailbox: config.supportMailboxPublicKey,
        supportMailboxKey: undefined,

        metadata_cache: {},
        channel_cache: {},
        cache_checks: {},

        queueStorage: WriteQueue(),
        queueDeletes: WriteQueue(),
        queueValidation: WriteQueue(),
        queueMetadata: WriteQueue(),

        batchIndexReads: BatchRead("HK_GET_INDEX"),
        batchMetadata: BatchRead('GET_METADATA'),
        batchRegisteredUsers: BatchRead("GET_REGISTERED_USERS"),
        batchDiskUsage: BatchRead('GET_DISK_USAGE'),
        batchUserPins: BatchRead('LOAD_USER_PINS'),
        batchTotalSize: BatchRead('GET_TOTAL_SIZE'),
        batchAccountQuery: BatchRead("QUERY_ACCOUNT_SERVER"),

        intervals: {},
        maxUploadSize: config.maxUploadSize || (20 * 1024 * 1024),
        premiumUploadSize: false, // overridden below...
        Sessions: {},
        paths: {},
        //msgStore: config.store,

        // /api/broadcast
        lastBroadcastHash: '',
        surveyURL: undefined,
        maintenance: undefined,

        netfluxUsers: {},

        pinStore: undefined,

        limits: {},
        admins: [],
        installToken: undefined,
        WARN: function (e, output) { // TODO deprecate this
            if (!Env.Log) { return; }
            if (e && output) {
                Env.Log.warn(e, {
                    output: output,
                    message: String(e),
                    stack: new Error(e).stack,
                });
            }
        },

        // as of 4.14.0 you need to opt-in to remote embedding.
        enableEmbedding: false,

/*  FIXME restrictRegistration is initialized as false and then overridden by admin decree
    There is a narrow window in which someone could register before the server updates this value.
    See also the cached 'restrictRegistration' value in server.js#serveConfig
*/
        restrictRegistration: false,
        blockDailyCheck: config.blockDailyCheck === true,

        consentToContact: false,
        listMyInstance: false,
        provideAggregateStatistics: false,
        updateAvailable: undefined,

        instanceName: {},
        instanceDescription: {},
        instanceJurisdiction: {},
        instanceNotice: {},

        customLimits: {},
        // FIXME this attribute isn't in the default conf
        // but it is referenced in Quota
        domain: config.domain,

        maxWorkers: undefined,
        disableIntegratedTasks: config.disableIntegratedTasks || false,
        disableIntegratedEviction: typeof(config.disableIntegratedEviction) === 'undefined'? true: config.disableIntegratedEviction,
        lastEviction: +new Date(),
        evictionReport: {},
        commandTimers: {},

        sso: plugins?.SSO?.config || {},
        enforceMFA: config.enforceMFA,

        ...(getInstalledOOVersions().length > 0
            ? {
                  onlyOffice: {
                      availableVersions: getInstalledOOVersions(),
                  },
              }
            : {}),


        // initialized as undefined
        bearerSecret: void 0,
        curvePrivate: curve.secretKey,
        curvePublic: Util.encodeBase64(curve.publicKey),

        selfDestructTo: {},
    };

    Object.keys(plugins || {}).forEach(name => {
        let plugin = plugins[name];
        if (!plugin.customizeEnv) { return; }
        try { plugin.customizeEnv(Env); }
        catch (e) {}
    });


    (function () {
        var max = config.maxWorkers;
        // if the supplied value is not a positive number, leave maxWorkers undefined
        // one worker will be created for each CPU core
        if (typeof(max) !== 'number' || isNaN(max) || max < 1) { return; }
        Env.maxWorkers = max;
    }());

    (function () {
    // mode can be FRESH (default), DEV, or PACKAGE
        if (process.env.PACKAGE) {
        // `PACKAGE=1 node server` uses the version string from package.json as the cache string
            //console.log("PACKAGE MODE ENABLED");
            Env.FRESH_MODE = false;
            Env.DEV_MODE = false;
        } else if (process.env.DEV) {
        // `DEV=1 node server` will use a random cache string on every page reload
            //console.log("DEV MODE ENABLED");
            Env.FRESH_MODE = false;
            Env.DEV_MODE = true;
        } else {
        // `FRESH=1 node server` will set a random cache string when the server is launched
        // and use it for the process lifetime or until it is reset from the admin panel
            //console.log("FRESH MODE ENABLED");
            Env.FRESH_KEY = +new Date();
        }

        // Offline mode is mostly for development. It lets us test clientside cache and offline support
        if (process.env.OFFLINE) { Env.OFFLINE_MODE = true; }
    }());

    Env.checkCache = function (channel) {
        var f = Env.cache_checks[channel] || Util.throttle(function () {
            delete Env.cache_checks[channel];
            if (Env.channel_cache[channel]) { return; }
            delete Env.metadata_cache[channel];
        }, 30000);
        f();
    };

    (function () {
        var custom = config.customLimits;
        if (!custom) { return; }

        var stored = Env.customLimits;

        Object.keys(custom).forEach(function (k) {
            var unsafeKey = Keys.canonicalize(k);

            if (!unsafeKey) {
                console.log("INVALID_CUSTOM_LIMIT_ID", {
                    message: "A custom quota upgrade was provided via your config with an invalid identifier. It will be ignored.",
                    key: k,
                    value: custom[k],
                });
                return;
            }

            if (stored[unsafeKey]) {
                console.log("INVALID_CUSTOM_LIMIT_DUPLICATED", {
                    message: "A duplicated custom quota upgrade was provided via your config which would have overridden an existing value. It will be ignored.",
                    key: k,
                    value: custom[k],
                });
                return;
            }

            if (!Quota.isValidLimit(custom[k])) {
                console.log("INVALID_CUSTOM_LIMIT_VALUE", {
                    message: "A custom quota upgrade was provided via your config with an invalid value. It will be ignored.",
                    key: k,
                    value: custom[k],
                });
                return;
            }

            var limit = stored[unsafeKey] = Util.clone(custom[k]);
            limit.origin = 'config';
        });
    }());

    (function () {
        var pes = config.premiumUploadSize;
        if (!isNaN(pes) && pes >= Env.maxUploadSize) {
            Env.premiumUploadSize = pes;
        }
    }());

    var paths = Env.paths;

    var keyOrDefaultString = function (key, def) {
        return Path.resolve(typeof(config[key]) === 'string'? config[key]: def);
    };

    Env.incrementBytesWritten = function (n) {
        if (!Env.enableProfiling) { return; }
        if (!n || typeof(n) !== 'number' || n < 0) { return; }
        Env.bytesWritten += n;
        setTimeout(function () {
            Env.bytesWritten -= n;
        }, Env.profilingWindow);
    };

    paths.pin = keyOrDefaultString('pinPath', './pins');
    paths.block = keyOrDefaultString('blockPath', './block');
    paths.data = keyOrDefaultString('filePath', './datastore');
    paths.staging = keyOrDefaultString('blobStagingPath', './blobstage');
    paths.blob = keyOrDefaultString('blobPath', './blob');
    paths.decree = keyOrDefaultString('decreePath', './data/');
    paths.base = keyOrDefaultString('base', './data');
    paths.archive = keyOrDefaultString('archivePath', './data/archive');
    paths.task = keyOrDefaultString('taskPath', './tasks');

    Env.defaultStorageLimit = typeof(config.defaultStorageLimit) === 'number' && config.defaultStorageLimit >= 0?
        config.defaultStorageLimit:
        Core.DEFAULT_LIMIT;

    try {
        Env.adminsData = (config.adminKeys || []).slice();
        Env.admins = (config.adminKeys || []).map(function (k) {
            try {
                return Keys.canonicalize(k);
            } catch (err) {
                return;
            }
        }).filter(Boolean);
    } catch (e) {
        console.error("Can't parse admin keys. Please update or fix your config.js file!");
    }

    try {
        let moderators = require('./commands/moderators').getKeysSync(Env);
        Env.moderators = moderators.map(safeKey => {
            return Util.unescapeKeyCharacters(safeKey);
        }) || [];
    } catch (e) {
        console.log(e);
        console.error("Can't parse support keys.");
    }

    Env.envUpdated = Util.mkEvent();
    Env.cacheFlushed = Util.mkEvent();

    return Env;
};

// don't serialize these things
const BAD = [
    'Log',
    'envUpdated',
    'cacheFlushed',
    'evictionReports',
    'commandTimers',
    'metadata_cache',
    'channel_cache',
    'cache_checks',
    'intervals',
    'Sessions',
    'netfluxUsers',
    'limits',
    'customLimits',
    'scheduleDecree',
    'plugins',

    'httpServer',

    'pinStore',
    'msgStore',
    'store',
    'blobStore',
];

module.exports.serialize = function (Env) {
    return JSON.stringify(Env, function (key, value) {
        if (value === Env) { return value; }
        if (BAD.includes(key)) { return; }

        if (typeof(value) === 'function') { return; }
        //console.log('serializing', { key, value, });
        if (Util.isCircular(value)) { return; }
        return value;
    });
};
