"use strict";
// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.configReady = exports.showHelp = exports.configFile = exports.saveConfigAsap = exports.currentVersion = exports.Version = exports.versionToScalar = void 0;
exports.defineConfig = defineConfig;
exports.configKeyExists = configKeyExists;
exports.getConfig = getConfig;
exports.getWholeConfig = getWholeConfig;
exports.setConfig = setConfig;
exports.subMultipleConfigs = subMultipleConfigs;
const const_1 = require("./const");
const watchLoad_1 = require("./watchLoad");
const yaml_1 = __importDefault(require("yaml"));
const lodash_1 = __importDefault(require("lodash"));
const cross_1 = require("./cross");
const debounceAsync_1 = require("./debounceAsync");
const fs_1 = require("fs");
const path_1 = require("path");
const events_1 = __importDefault(require("./events"));
const promises_1 = require("fs/promises");
const argv_1 = require("./argv");
const util_files_1 = require("./util-files");
// keep definition of config properties
const configProps = {};
let started = false; // this will tell the difference for subscribeConfig()s that are called before or after config is loaded
let state = {}; // current state of config properties
const filePath = (0, cross_1.with_)(argv_1.argv.config || process.env.HFS_CONFIG, p => {
    if (!p)
        return const_1.CONFIG_FILE;
    p = (0, path_1.resolve)(const_1.ORIGINAL_CWD, p);
    try {
        if ((0, fs_1.statSync)(p).isDirectory()) // try to detect if path points to a folder, in which case we add the standard filename
            return (0, path_1.join)(p, const_1.CONFIG_FILE);
    }
    catch { }
    return p;
});
// takes a semver like 1.2.3-alpha1, but alpha and beta numbers must share the number progression
exports.versionToScalar = lodash_1.default.memoize((ver) => {
    // this regexp is supposed to be resistant to optional leading "v" and an optional custom name after a space
    const res = /^v?(\d+)\.(\d+)\.(\d+)(?:-\D+([0-9.]+))?/.exec(ver);
    if (!res)
        return NaN;
    const [, a, b, c, beta] = res.map(Number);
    const officialScalar = c + b * 1E3 + a * 1E6; // gives 3 digits for each number
    const betaScalar = 1 / (1 + beta || Infinity); // beta tends to 0, while non-beta is 0. +1 to make it work even in case of alpha0
    return officialScalar - betaScalar;
});
class Version extends String {
    scalar;
    constructor(v) {
        super(v);
        this.scalar = (0, exports.versionToScalar)(v);
    }
    olderThan(otherVersion) {
        return this.scalar < (0, exports.versionToScalar)(otherVersion);
    }
}
exports.Version = Version;
const CONFIG_CHANGE_EVENT_PREFIX = 'config.';
exports.currentVersion = new Version(const_1.VERSION);
const configVersion = defineConfig('version', const_1.VERSION, v => new Version(v));
function defineConfig(k, defaultValue, compiler) {
    configProps[k] = { defaultValue };
    const object = {
        key() {
            return k;
        },
        get() {
            return getConfig(k);
        },
        async getWhenReady() {
            await exports.configReady;
            return this.get();
        },
        sub(cb) {
            if (started) // initial event already passed, we'll make the first call
                cb(getConfig(k), { k, was: defaultValue, defaultValue, version: configVersion.compiled(), object });
            return events_1.default.on(CONFIG_CHANGE_EVENT_PREFIX + k, (v, was, version, onlyCompileChanged) => {
                if (stack.includes(cb))
                    return; // avoid infinite loop in case a subscriber changes the value
                stack.push(cb);
                try {
                    return cb(v, { k, was, version, defaultValue, object, onlyCompileChanged });
                }
                finally {
                    stack.pop();
                }
            }, { warnAfter: 1000 }); // e.g. each plugin watch enable_plugins
        },
        set(v) {
            if (typeof v === 'function') {
                const draft = structuredClone(this.get());
                this.set(v(draft) ?? draft); // use return value if provided
            }
            else
                setConfig1(k, v);
        },
        compiled: () => (compiler ? compiled : (0, cross_1.throw_)("missing compiler")),
        setCompiled(v) {
            compiled = v;
            const was = getConfig(k);
            return events_1.default.emitAsync(CONFIG_CHANGE_EVENT_PREFIX + k, was, was, const_1.VERSION, true);
        }
    };
    let compiled = compiler?.(defaultValue, { k, version: exports.currentVersion, defaultValue, object });
    if (compiler)
        object.sub((v, more) => {
            if (!more.onlyCompileChanged)
                return compiled = compiler(v, more);
        });
    return object;
}
function configKeyExists(k) {
    return configProps.hasOwnProperty(k);
}
const stack = [];
function getConfig(k) {
    return state[k] ?? lodash_1.default.cloneDeep(configProps[k]?.defaultValue); // clone to avoid changing
}
function getWholeConfig({ omit, only }) {
    const defs = (0, cross_1.newObj)(configProps, x => x.defaultValue);
    let copy = lodash_1.default.defaults({}, state, defs);
    if (omit?.length)
        copy = lodash_1.default.omit(copy, omit);
    if (only)
        copy = lodash_1.default.pick(copy, only);
    return lodash_1.default.cloneDeep(copy);
}
// pass a value to `save` to force saving decision, or leave undefined for auto. Passing false will also reset previously loaded configs.
async function setConfig(newCfg, save) {
    const version = lodash_1.default.isString(newCfg.version) ? new Version(newCfg.version) : undefined;
    const considerEnvs = !process.env['HFS_ENV_BOOTSTRAP'] || !started && lodash_1.default.isEmpty(newCfg);
    // first time we consider also CLI args
    const argCfg = !started && lodash_1.default.pickBy((0, cross_1.newObj)(configProps, (_x, k) => (0, cross_1.tryJson)(k in argv_1.argv ? argv_1.argv[k] : considerEnvs ? process.env['HFS_' + k.toUpperCase().replaceAll('-', '_')] : '', lodash_1.default.identity)), x => x !== undefined);
    if (!lodash_1.default.isEmpty(argCfg)) {
        (0, exports.saveConfigAsap)(); // don't set `save` argument, as it would interfere below, at check `save===false`
        Object.assign(newCfg, argCfg);
    }
    await Promise.allSettled(Object.keys(newCfg).map(k => apply(k, newCfg[k])));
    if (save) {
        (0, exports.saveConfigAsap)();
        return;
    }
    if (started) {
        if (save === false) // false is used when loading whole config, and in such case we should not leave previous values untreated. Also, we need this only after we already `started`.
            await Promise.allSettled(Object.keys(state).map(k => newCfg.hasOwnProperty(k) || apply(k, undefined)));
        return;
    }
    // first time we emit also for the default values
    await Promise.allSettled(Object.keys(configProps).map(k => newCfg.hasOwnProperty(k) || apply(k, undefined, true)));
    started = true;
    events_1.default.emit('configReady', startedWithoutConfig);
    if (version?.valueOf() !== const_1.VERSION) // be sure to save the new version in the file
        (0, exports.saveConfigAsap)();
    function apply(k, newV, isDefault = false) {
        return setConfig1(k, newV, save === undefined, argCfg && k in argCfg || isDefault ? exports.currentVersion : version);
    }
}
async function setConfig1(k, newV, saveChanges = true, valueVersion) {
    if (lodash_1.default.isPlainObject(newV))
        newV = lodash_1.default.pickBy(newV, x => x !== undefined);
    const def = configProps[k]?.defaultValue;
    if (same(newV ?? null, def ?? null))
        newV = undefined;
    if (started && same(newV, state[k]))
        return; // no change
    const was = getConfig(k); // include cloned default, if necessary
    state[k] = newV;
    await events_1.default.emitAsync(CONFIG_CHANGE_EVENT_PREFIX + k, getConfig(k), was, valueVersion, false);
    if (saveChanges)
        (0, exports.saveConfigAsap)();
    function same(a, b) {
        return a === b || JSON.stringify(a) === JSON.stringify(b);
    }
}
const saveDebounced = (0, debounceAsync_1.debounceAsync)(async () => {
    while (!started)
        await (0, cross_1.wait)(100);
    // keep backup
    const bak = filePath + '.bak';
    const aWeekAgo = Date.now() - cross_1.DAY * 7;
    if (await (0, util_files_1.statWithTimeout)(bak).then(x => aWeekAgo > x.mtimeMs, () => true))
        await (0, promises_1.copyFile)(filePath, bak).catch(() => { }); // ignore errors
    await exports.configFile.save(stringify({
        ...state,
        version: const_1.VERSION,
        platform: `${process.platform}-${process.arch}${(0, cross_1.prefix)('-', !const_1.IS_BINARY && (0, path_1.basename)(process.execPath))}`,
    })).catch(err => console.error('Failed at saving config file, please ensure it is writable.', String(err)));
});
const saveConfigAsap = () => void saveDebounced();
exports.saveConfigAsap = saveConfigAsap;
function stringify(obj) {
    return yaml_1.default.stringify(obj, { lineWidth: 1000 });
}
let startedWithoutConfig = false;
console.log("Config", filePath);
exports.configFile = (0, watchLoad_1.watchLoad)(filePath, text => {
    startedWithoutConfig = !text;
    try {
        return setConfig(yaml_1.default.parse(text, { uniqueKeys: false }) || {}, false);
    }
    catch (e) {
        console.error("Error in", filePath, ':', e.message || String(e));
    }
}, {
    immediateFirst: true,
    failedOnFirstAttempt() {
        startedWithoutConfig = true;
        console.log("No config file, using defaults");
        setTimeout(() => // this is called synchronously, but we need to call setConfig after first tick, when all configs are defined
         setConfig({}, false));
    }
});
function subMultipleConfigs(cb, configs) {
    // we depend on multiple configs, so wait for all of them to be ready
    const unsub_s = exports.configReady.then(() => configs.map(x => x.sub(cb)));
    return async () => {
        for (const x of await unsub_s)
            x();
    };
}
exports.showHelp = argv_1.argv.help;
exports.configReady = events_1.default.once('configReady').then(x => x[0]); // the value is startedWithoutConfig. The .then also avoids exposing the cancel-subscription function.
exports.configReady.then(() => {
    if (!exports.showHelp)
        return;
    console.log(`HELP
You can pass any configuration in the form: --name value
Most common configurations:
    --create-admin <password>
    --port <port>
    --cert <path>
    --private_key <path>    
    --consoleFile <path>

For a description of each configuration, please refer to https://rejetto.com/hfs-config
Other options:
    --debug will print extra information
    `);
    process.exit(0);
});
