"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.OptionsWatcher = exports.CannotSetPropertyError = exports.KeyDoesNotExistError = exports.InvalidValueTypeError = exports.InvariantUninitializedOptionsWatcherError = exports.UninitializedOptionsWatcherError = exports.OptionsWatcherConfigFileError = void 0;
const harper_logger_js_1 = require("../utility/logging/harper_logger.js");
const events_1 = require("events");
const yaml_1 = __importDefault(require("yaml"));
const chokidar_1 = __importDefault(require("chokidar"));
const promises_1 = require("node:fs/promises");
const util_1 = require("util");
const DEFAULT_CONFIG_js_1 = require("./DEFAULT_CONFIG.js");
const lodash_1 = require("lodash");
// This is uniquely for errors coming from the chokidar watcher of the config file.
class OptionsWatcherConfigFileError extends Error {
    constructor(configFilePath, error) {
        super(`Error watching config file ${configFilePath}: ${typeof error === 'object' && error !== null && 'message' in error ? error.message : error}`);
        this.name = 'OptionsWatcherConfigFileError';
    }
}
exports.OptionsWatcherConfigFileError = OptionsWatcherConfigFileError;
class UninitializedOptionsWatcherError extends Error {
    constructor() {
        super('OptionsWatcher has not been initialized yet. Await `ready()` or the `ready` event of the respective OptionsWatcher instance.');
        this.name = 'UninitializedOptionsWatcherError';
    }
}
exports.UninitializedOptionsWatcherError = UninitializedOptionsWatcherError;
class InvariantUninitializedOptionsWatcherError extends Error {
    constructor() {
        super('Invariant: OptionsWatcher has not been initialized yet. This should never happen.');
        this.name = 'InvariantUninitializedOptionsWatcherError';
    }
}
exports.InvariantUninitializedOptionsWatcherError = InvariantUninitializedOptionsWatcherError;
class InvalidValueTypeError extends Error {
    constructor(keys, value) {
        super(`Invalid value type for key ${keys.join('.')}. Expected object, string, array, number, boolean, or undefined. Received ${typeof value}.`);
        this.name = 'InvalidValueTypeError';
    }
}
exports.InvalidValueTypeError = InvalidValueTypeError;
class KeyDoesNotExistError extends Error {
    constructor(keys, key) {
        super(`Cannot set property ${keys.join('.')} as ${key} does not exist.`);
        this.name = 'KeyDoesNotExistError';
    }
}
exports.KeyDoesNotExistError = KeyDoesNotExistError;
class CannotSetPropertyError extends Error {
    constructor(keys) {
        super(`Cannot set property ${keys.join('.')} as parent is not an object.`);
        this.name = 'CannotSetPropertyError';
    }
}
exports.CannotSetPropertyError = CannotSetPropertyError;
/**
 * Watches a YAML configuration file for changes and provides methods to access the configuration.
 *
 * @emits ready - When the configuration file is initially loaded and values are available
 * @emits change - When any value in the configuration changes (with key, new value, and full config)
 * @emits remove - When the configuration file is removed or the extension is removed from the config
 * @emits error - When an error occurs reading or parsing the file
 * @emits close - When the watcher is closed
 */
class OptionsWatcher extends events_1.EventEmitter {
    #filePath;
    #watcher;
    #scopedConfig;
    #rootConfig;
    #name;
    #logger;
    ready;
    constructor(name, filePath, logger) {
        super();
        this.#name = name;
        this.#filePath = filePath;
        this.#logger = logger || (0, harper_logger_js_1.loggerWithTag)(name);
        this.ready = (0, events_1.once)(this, 'ready');
        this.#watcher = chokidar_1.default
            .watch(filePath, { persistent: false })
            .on('add', this.#handleChange.bind(this))
            .on('change', this.#handleChange.bind(this))
            .on('error', this.#handleError.bind(this))
            .on('unlink', this.#handleUnlink.bind(this))
            .on('ready', this.#handleChange.bind(this));
    }
    #handleChange() {
        (0, promises_1.readFile)(this.#filePath, 'utf-8')
            .then((contents) => {
            this.#rootConfig = yaml_1.default.parse(contents);
            // If the extension is in the config file
            if (this.#rootConfig && this.#name in this.#rootConfig) {
                // If a config object does not exist
                if (!this.#scopedConfig) {
                    // set it
                    this.#scopedConfig = this.#rootConfig[this.#name];
                    // and emit a ready event
                    this.emit('ready', this.#scopedConfig);
                }
                else {
                    // Otherwise, merge the new config with the old config
                    this.#merge(this.#rootConfig[this.#name], this.#scopedConfig);
                }
            }
            else {
                // Otherwise, if the extension is not in the config file
                // This means the plugin was removed from the config file
                if (this.#scopedConfig) {
                    // and a config exists, remove it
                    this.#scopedConfig = undefined;
                    this.emit('remove');
                }
                // Otherwise do nothing - the user may add the config back in later
            }
        })
            .catch((error) => {
            // If the config file does not exist
            if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
                // And a config already exists, reset it to the default
                if (this.#rootConfig) {
                    this.#resetConfig();
                    this.emit('remove');
                }
                else {
                    // Otherwise, if no config exists, then just set to default and emit ready
                    this.#resetConfig();
                    this.emit('ready');
                }
                return;
            }
            this.emit('error', error);
        });
    }
    #handleError(error) {
        this.emit('error', new OptionsWatcherConfigFileError(this.#filePath, error));
    }
    #handleUnlink(path) {
        this.#logger.warn?.(`Configuration file ${path} was deleted. Reverting to default configuration. Recreate it to restore the options watcher.`);
        this.#resetConfig();
        this.emit('remove');
    }
    #resetConfig() {
        this.#rootConfig = DEFAULT_CONFIG_js_1.DEFAULT_CONFIG;
        this.#scopedConfig = this.#rootConfig[this.#name];
    }
    /**
     * This merge algorithm is best thought off as a diff and overwrite.
     * The new config object will completely overwrite the old config object,
     * but we need to recursively iterate over the new entries and emit the
     * necessary change events.
     *
     * All events are considered to be a `change`.
     */
    #merge(newConfigValue, currentConfigValue, prevKeys = []) {
        // First, ensure current and new config values are Config objects (not null, undefined, or a primitive)
        if (!this.#isConfig(currentConfigValue) || !this.#isConfig(newConfigValue)) {
            // If either is not a config, then just set as there is no need to diff/merge
            this.#setValue(prevKeys, newConfigValue);
            return;
        }
        // Check for any missing keys (new config has removed keys from current config)
        for (const key of Object.keys(currentConfigValue)) {
            if (!(key in newConfigValue)) {
                this.#setValue(prevKeys.concat(key), undefined);
            }
        }
        // Then, iterate of the keys in the new config and check for any changes to the current config
        for (const [key, newValue] of Object.entries(newConfigValue)) {
            const keys = prevKeys.concat(key);
            const currentValue = this.#getValue(keys);
            // If the new value is not the same type as the current value, then no equivalency check is necessary
            // Just set the value and continue
            if (typeof newValue !== typeof currentValue ||
                // one exception to the above rule is if the `currentValue` is being changed from an array to an object or vice versa
                // Check for this and shortcut as it can be treated as a type change
                (Array.isArray(newValue) && !Array.isArray(currentValue)) ||
                (!Array.isArray(newValue) && Array.isArray(currentValue))) {
                this.#setValue(keys, newValue);
                continue;
            }
            // If the new value is an object (non null nor an array), now merge it with the current value
            if (!Array.isArray(newValue) && typeof newValue === 'object' && newValue !== null) {
                if (this.#isConfig(currentValue)) {
                    // Now we're sure currentValue is a Config
                    this.#merge(newValue, currentValue, keys);
                }
                else {
                    // If currentValue is not a Config, just set newValue
                    this.#setValue(keys, newValue);
                }
                continue;
            }
            if (!(0, util_1.isDeepStrictEqual)(newValue, currentValue)) {
                this.#setValue(keys, newValue);
            }
        }
    }
    #isConfig(value) {
        return typeof value === 'object' && value !== null && value !== undefined && !Array.isArray(value);
    }
    #getValue(keys) {
        let value = this.#scopedConfig;
        for (const key of keys) {
            if (value === null || value === undefined || typeof value !== 'object' || !(key in value))
                return undefined;
            value = value[key];
        }
        return (0, lodash_1.cloneDeep)(value);
    }
    #setValue(keys, value) {
        // This method is only called by `merge`, which is only called by `changeHandler` if `this.#config` is defined.
        // So this should never happen, but just in case, throw an error.
        // If this ever does get triggered:
        // - Did something else other than `merge` call this method?
        // - Did the `merge` method get called differently?
        // - Did the `merge` method become async and the `this.#config` get set to undefined sometime in between?
        if (!this.#scopedConfig) {
            throw new InvariantUninitializedOptionsWatcherError();
        }
        if (!['object', 'string', 'array', 'number', 'boolean', 'undefined'].includes(typeof value)) {
            throw new InvalidValueTypeError(keys, value);
        }
        let obj = this.#scopedConfig;
        for (const key of keys.slice(0, -1)) {
            if (obj === null || obj === undefined || typeof obj !== 'object' || !(key in obj)) {
                throw new KeyDoesNotExistError(keys, key);
            }
            obj = obj[key];
        }
        if (obj === null || obj === undefined || typeof obj !== 'object') {
            throw new CannotSetPropertyError(keys);
        }
        obj[keys[keys.length - 1]] = value;
        this.emit('change', keys, value, this.#scopedConfig);
    }
    /**
     * Closes the underlying file watcher, emits the `close` event, and removes any listeners on the OptionsWatcher instance
     */
    close() {
        this.#watcher.close();
        this.emit('close');
        this.removeAllListeners();
        return this;
    }
    /**
     * Get a value from the configuration using an array of strings representing the key.
     *
     * For example, if the configuration is:
     * ```yaml
     * foo:
     *  bar:
     *   baz: 42
     * ```
     * Then `get(['foo','bar','baz'])` will return `42`.
     *
     * If the key does not exist, `undefined` will be returned.
     * @param key an array of strings representing the key.
     * @returns
     */
    get(key) {
        return this.#scopedConfig ? this.#getValue(key) : undefined;
    }
    /**
     * Get the entire configuration object.
     *
     * @returns A deep clone of the entire configuration object.
     */
    getAll() {
        return (0, lodash_1.cloneDeep)(this.#scopedConfig);
    }
    /**
     * Get the entire root configuration object from the config file.
     */
    getRoot() {
        return this.#rootConfig;
    }
}
exports.OptionsWatcher = OptionsWatcher;
//# sourceMappingURL=OptionsWatcher.js.map