src/settings.js

const assert = require('assert');

/**
 * Strings describing why a setting update failed.
 * @memberof Settings
 * @readonly
 * @enum {string}
 */
const UpdateRejectionReason = {
  NOT_ADMIN: 'not admin',
  INVALID_VALUE: 'invalid value',
  SETTING_DOES_NOT_EXIST: 'that setting doesn\'t exist',
  NOT_ALLOWED_IN_SERVER: 'that setting cannot be set per-server',
  NOT_ALLOWED_IN_CHANNEL: 'that setting cannot be set per-channel',
  NOT_ALLOWED_FOR_USER: 'that setting cannot be set per-user',
};

const SettingScope = {
  SERVER: 'server',
  CHANNEL: 'channel',
  USER: 'user',
};

/**
 * The result of an attempted setting update.
 * @typedef {Object} Settings~SettingUpdateResult
 * @type {Object}
 * @property {boolean} accepted - Whether or not the update was applied.
 * @property {Object} [setting] - The setting that was (or wasn't) updated (only present if a matching setting was found)
 * @property {Settings.UpdateRejectionReason} [reason] - Why the update failed (only present if accepted is false)
 * @property {string} [rejectedUserFacingValue] - The user facing value that was rejected (only present if reason === UpdateRejectionReason.INVALID_VALUE)
 * @property {string} [nonExistentUniqueId] - The unique ID that the caller tried to change the setting value for (only present if reason === UpdateRejectionReason.SETTING_DOES_NOT_EXIST)
 * @property {string} [newUserFacingValue] - The new user facing value that the setting was updated to (only if accepted === true)
 * @property {string} [newInternalValue] - The new internal value that the setting was updated to (only if accepted === true)
 */

function createUpdateRejectionResultUserNotAdmin(treeNode) {
  return {
    accepted: false,
    reason: UpdateRejectionReason.NOT_ADMIN,
    setting: treeNode,
  };
}

function createUpdateRejectionResultValueInvalid(rejectedUserFacingValue, treeNode) {
  return {
    accepted: false,
    reason: UpdateRejectionReason.INVALID_VALUE,
    rejectedUserFacingValue: rejectedUserFacingValue,
    setting: treeNode,
  };
}

function createUpdateRejectionResultNoSuchSetting(settingUniqueId) {
  return {
    accepted: false,
    reason: UpdateRejectionReason.SETTING_DOES_NOT_EXIST,
    nonExistentUniqueId: settingUniqueId,
  };
}

function createUpdateRejectionResultNotInServer(treeNode) {
  return {
    accepted: false,
    reason: UpdateRejectionReason.NOT_ALLOWED_IN_SERVER,
    setting: treeNode,
  };
}

function createUpdateRejectionResultNotInChannel(treeNode) {
  return {
    accepted: false,
    reason: UpdateRejectionReason.NOT_ALLOWED_IN_CHANNEL,
    setting: treeNode,
  };
}

function createUpdateRejectionResultNotForUser(treeNode) {
  return {
    accepted: false,
    reason: UpdateRejectionReason.NOT_ALLOWED_FOR_USER,
    setting: treeNode,
  };
}

function createUpdateAcceptedResult(newUserFacingValue, newInternalValue, treeNode) {
  return {
    accepted: true,
    newUserFacingValue: newUserFacingValue,
    newInternalValue: newInternalValue,
    setting: treeNode,
  };
}

function getUserSetting(userData, settingUniqueId) {
  if (userData.settings && userData.settings.global) {
    return userData.settings.global[settingUniqueId];
  }

  return undefined;
}

function getServerSetting(serverData, settingUniqueId) {
  if (serverData.settings && serverData.settings.serverSettings) {
    return serverData.settings.serverSettings[settingUniqueId];
  }

  return undefined;
}

function getChannelSetting(serverData, channelId, settingUniqueId) {
  if (
    serverData.settings
    && serverData.settings.channelSettings
    && serverData.settings.channelSettings[channelId]
  ) {
    return serverData.settings.channelSettings[channelId][settingUniqueId];
  }

  return undefined;
}

function getTreeNodeForUniqueId(settingsTree, settingUniqueId) {
  for (const element of settingsTree) {
    if (element.uniqueId === settingUniqueId) {
      return element;
    }
    if (element.children) {
      const childTreeResult = getTreeNodeForUniqueId(element.children, settingUniqueId);
      if (childTreeResult) {
        return childTreeResult;
      }
    }
  }

  return undefined;
}

function defaultUpdateUserSettingValue(persistence, settingUniqueId, userId, newInternalValue) {
  assert(userId);
  return persistence.editDataForUser(userId, userData => {
    userData.settings = userData.settings || {};
    userData.settings.global = userData.settings.global || {};
    userData.settings.global[settingUniqueId] = newInternalValue;
    return userData;
  });
}

function defaultUpdateChannelSettingValue(persistence, settingUniqueId, serverId, channelId, newInternalValue) {
  assert(serverId);
  assert(channelId);
  return persistence.editDataForServer(serverId, serverData => {
    serverData.settings = serverData.settings || {};
    serverData.settings.channelSettings = serverData.settings.channelSettings || {};
    serverData.settings.channelSettings[channelId] = serverData.settings.channelSettings[channelId] || {};
    serverData.settings.channelSettings[channelId][settingUniqueId] = newInternalValue;
    return serverData;
  });
}

function defaultUpdateServerWideSettingValue(persistence, settingUniqueId, serverId, newInternalValue) {
  assert(serverId);
  return persistence.editDataForServer(serverId, serverData => {
    serverData.settings = serverData.settings || {};
    serverData.settings.serverSettings = serverData.settings.serverSettings || {};
    serverData.settings.serverSettings[settingUniqueId] = newInternalValue;

    if (serverData.settings.channelSettings) {
      delete serverData.settings.channelSettings[settingUniqueId];
    }

    return serverData;
  });
}

function defaultUpdateSetting(persistence, settingUniqueId, serverId, channelId, userId, newInternalValue, settingScope) {
  assert(
    settingScope === SettingScope.SERVER
    || settingScope === SettingScope.CHANNEL
    || settingScope === SettingScope.USER);

  if (settingScope === SettingScope.SERVER) {
    return defaultUpdateServerWideSettingValue(persistence, settingUniqueId, serverId, newInternalValue);
  } else if (settingScope === SettingScope.CHANNEL) {
    return defaultUpdateChannelSettingValue(persistence, settingUniqueId, serverId, channelId, newInternalValue);
  } else {
    return defaultUpdateUserSettingValue(persistence, settingUniqueId, userId, newInternalValue);
  }
}

async function defaultGetInternalSettingValue(persistence, setting, serverId, channelId, userId) {
  const [userData, serverData] = await Promise.all([
    persistence.getDataForUser(userId),
    persistence.getDataForServer(serverId),
  ]);

  const userSetting = getUserSetting(userData, setting.uniqueId);
  const channelSetting = getChannelSetting(serverData, channelId, setting.uniqueId);
  const serverSetting = getServerSetting(serverData, setting.uniqueId);

  if (userSetting !== undefined) {
    return userSetting;
  }
  if (channelSetting !== undefined) {
    return channelSetting;
  }
  if (serverSetting !== undefined) {
    return serverSetting;
  }

  const defaultUserFacingValue = setting.defaultUserFacingValue;
  const defaultInternalValue = await setting.convertUserFacingValueToInternalValue(defaultUserFacingValue);
  return defaultInternalValue;
}

function sanitizeAndValidateSettingsLeaf(treeNode, parent, uniqueIdsEncountered, path) {
  const uniqueId = treeNode.uniqueId;
  let errorMessage = '';

  /* Validate */

  if (!treeNode.userFacingName) {
    errorMessage = 'Invalid or nonexistent userFacingName property';
  } else if (!treeNode.uniqueId) {
    errorMessage = 'Invalid or nonexistent uniqueId property.';
  } else if (uniqueIdsEncountered.indexOf(uniqueId) !== -1) {
    errorMessage = 'There is already a setting with that uniqueId';
  } else if (treeNode.defaultUserFacingValue === undefined) {
    errorMessage = 'No defaultUserFacingValue property.';
  } else if (treeNode.uniqueId.indexOf(' ') !== -1) {
    errorMessage = 'Setting unique IDs must not contain spaces.';
  }

  if (errorMessage) {
    throw new Error(`Error validating setting with uniqueId '${uniqueId}': ${errorMessage}`);
  }

  /* Provide defaults */

  if (treeNode.serverSetting === undefined) {
    treeNode.serverSetting = true;
  }

  if (treeNode.channelSetting === undefined) {
    treeNode.channelSetting = true;
  }

  if (treeNode.userSetting === undefined) {
    treeNode.userSetting = true;
  }

  if (treeNode.requireConfirmation === undefined) {
    treeNode.requireConfirmation = false;
  }

  treeNode.convertUserFacingValueToInternalValue = treeNode.convertUserFacingValueToInternalValue || (value => value);
  treeNode.convertInternalValueToUserFacingValue = treeNode.convertInternalValueToUserFacingValue || (value => `${value}`);
  treeNode.validateInternalValue = treeNode.validateInternalValue || (() => true);
  treeNode.updateSetting = treeNode.updateSetting || defaultUpdateSetting;
  treeNode.getInternalSettingValue = treeNode.getInternalSettingValue || defaultGetInternalSettingValue;
  treeNode.onServerSettingChanged = treeNode.onServerSettingChanged || (() => {});
  treeNode.onChannelSettingChanged = treeNode.onChannelSettingChanged || (() => {});
  treeNode.onUserSettingChanged = treeNode.onUserSettingChanged || (() => {});
  treeNode.path = path;
  treeNode.parent = parent;

  uniqueIdsEncountered.push(uniqueId);
}

function sanitizeAndValidateSettingsCategory(treeNode, parent, uniqueIdsEncountered, path) {
  if (!treeNode.userFacingName) {
    throw new Error('A settings category does not have a user facing name.');
  }

  treeNode.path = path;
  treeNode.parent = parent;
  sanitizeAndValidateSettingsTree(treeNode.children, treeNode, uniqueIdsEncountered, path);
}

function sanitizeAndValidateSettingsTree(settingsTree, parent, uniqueIdsEncountered = [], path = []) {
  if (!Array.isArray(settingsTree)) {
    throw new Error('The settings, or a setting category\'s children property, is not an array');
  }

  for (let i = 0; i < settingsTree.length; i += 1) {
    const treeNode = settingsTree[i];
    const childPath = path.slice();
    childPath.push(i);
    if (treeNode.children) {
      sanitizeAndValidateSettingsCategory(treeNode, parent || settingsTree, uniqueIdsEncountered, childPath);
    } else {
      sanitizeAndValidateSettingsLeaf(treeNode, parent || settingsTree, uniqueIdsEncountered, childPath);
    }
  }
}

function onSettingChanged(treeNode, settingScope, serverId, channelId, userId, newSettingValidationResult) {
  if (settingScope === SettingScope.USER) {
    return treeNode.onUserSettingChanged(treeNode, userId, newSettingValidationResult);
  } else if (settingScope === SettingScope.CHANNEL) {
    return treeNode.onChannelSettingChanged(treeNode, channelId, newSettingValidationResult);
  } else if (settingScope === SettingScope.SERVER) {
    return treeNode.onServerSettingChanged(treeNode, serverId, newSettingValidationResult);
  } else {
    assert(false, 'Unknown setting scope');
  }
}

/**
 * Represents one setting
 * @typedef {Object} Settings~Setting
 * @property {string} userFacingName - The name of the setting
 * @property {string} description - A description of the setting
 * @property {string} allowedValuesDescription - A description of what values the setting allows.
 * @property {string} uniqueId - A unique ID for the setting. Can be anything, and should not be changed.
 * @property {string} defaultUserFacingValue - The default user facing (string) value of the setting.
 * @property {boolean} [userSetting=true] - Whether the setting can be applied on a user-by-user basis.
 * @property {boolean} [channelSetting=true] - Whether the setting can be applied on a channel-by-channel basis.
 * @property {boolean} [serverSetting=true] - Whether the setting can be applied server-wide.
 * @property {function} [convertUserFacingValueToInternalValue] - A function that takes a user facing string value and returns
 *   the value you want to use and store internally. If omitted, no such conversion is performed.
 * @property {function} [convertInternalValueToUserFacingValue] - A function that takes an internal value and returns a user facing
 *   string value. If omitted, the internal value is simply stringified if not already a string.
 * @property {function} [validateInternalValue] - A function that takes an internal setting value and returns true if it's valid,
 *    false if it's not. If omitted, not validation is performed.
 */

/**
 * Represents a category of settings
 * @typedef {Object} Settings~SettingsCategory
 * @property {string} userFacingName - The name of the category
 * @property {Array<(Settings~SettingsCategory|Settings~Setting)>} children - An array of child categories, or settings leafs.
 */

/**
 * Get and set settings with server, channel, and user scope.
 * Settings can be accessed via {@link Monochrome#getSettings}.
 * Settings are specified by creating an array of [SettingsCategory]{@link Settings~SettingsCategory}s
 * and [Setting]{@link Settings~Setting}s in a javascript file and passing the path to that file
 * into the [Monochrome constructor]{@link Monochrome}.
 * For a simple example of a settings definition file, see [the monochrome demo]{@link https://github.com/mistval/monochrome-demo/blob/master/server_settings.js}.
 * For a more advanced example, see [Kotoba's settings definition file]{@link https://github.com/mistval/kotoba/blob/master/src/user_settings.js}.
 * For an example of using the settings module, see the [monochrome demo settings command]{@link https://github.com/mistval/monochrome-demo/blob/master/commands/settings.js}.
 * The demo settings command can be used in your bot. Just edit the configuration section at the top. If you use the demo settings command,
 * you may never need to interact with the settings module directly.
 * @hideconstructor
 */
class Settings {
  constructor(persistence, logger, settingsFilePath) {
    this.persistence_ = persistence;
    this.settingsTree_ = [];
    this.logger = logger.child({
      component: 'Monochrome::Settings',
    });

    if (settingsFilePath) {
      try {
        this.settingsTree_ = require(settingsFilePath);
      } catch (err) {
        this.logger.error({
          event: 'FAILED TO LOAD SETTINGS FILE',
          file: settingsFilePath,
          detail: `Failed to load from ${settingsFilePath}`,
          err,
        });
      }
    }

    sanitizeAndValidateSettingsTree(this.settingsTree_);
  }

  addNodeToRoot(node) {
    if (node) {
      this.settingsTree_.unshift(node);
      sanitizeAndValidateSettingsTree(this.settingsTree_);
    }
  }

  /**
   * Get the settings tree that you specified in your settings file.
   * It may not be exactly the same due to the application of default values.
   * @returns {Object[]} The array of settings at the root.
   */
  getRawSettingsTree() {
    return this.settingsTree_;
  }

  /**
   * Get the setting or category with the specified unique ID, or undefined if it doesn't exist.
   * @param {string} uniqueId
   * @returns {Object|undefined}
   */
  getTreeNodeForUniqueId(uniqueId) {
    return getTreeNodeForUniqueId(this.settingsTree_, uniqueId);
  }

  /**
   * Check if a user facing value is valid for a setting.
   * @param {Object} setting - The setting, found by calling getTreeNodeForUniqueId or by traversing
   *   the settings tree accessed via the getRawSettingsTree method.
   * @param {string} userFacingValue - The user facing value to check for validity.
   * @returns {boolean}
   */
  async userFacingValueIsValidForSetting(setting, userFacingValue) {
    const internalValue = await setting.convertUserFacingValueToInternalValue(userFacingValue);
    return setting.validateInternalValue(internalValue);
  }

  /**
   * Get the internal value for a setting given the current server, channel, and user context.
   * If there's a matching user setting value, it overrides any matching channel setting value, which overrides
   * any matching server setting value. If there are no server, channel, or user settings values set, the default
   * value specified in the settings definition is returned.
   * @param {string} settingUniqueId
   * @param {string} serverId - The ID of the server where the setting is being accessed.
   * @param {string} channelId - The ID of the channel where the setting is being accessed.
   * @param {string} userId - The ID of the user using the setting.
   * @returns {Object|undefined} The internal value of the setting, or undefined if no setting is found.
   */
  getInternalSettingValue(settingUniqueId, serverId, channelId, userId) {
    const treeNode = getTreeNodeForUniqueId(this.settingsTree_, settingUniqueId);
    if (!treeNode) {
      return undefined;
    }

    return treeNode.getInternalSettingValue(this.persistence_, treeNode, serverId, channelId, userId);
  }

  /**
   * Get the user facing value for a setting given the current server, channel, and user context.
   * If there's a matching user setting value, it overrides any matching channel setting value, which overrides
   * any matching server setting value. If there are no server, channel, or user settings values set, the default
   * value specified in the settings definition is returned.
   * @param {string} settingUniqueId
   * @param {string} serverId - The ID of the server where the setting is being accessed.
   * @param {string} channelId - The ID of the channel where the setting is being accessed.
   * @param {string} userId - The ID of the user using the setting.
   * @returns {string|undefined} The user facing value of the setting, or undefined if no setting is found.
   */
  async getUserFacingSettingValue(settingUniqueId, serverId, channelId, userId) {
    const treeNode = getTreeNodeForUniqueId(this.settingsTree_, settingUniqueId);
    if (!treeNode) {
      return undefined;
    }

    const internalValue = await this.getInternalSettingValue(settingUniqueId, serverId, channelId, userId);
    const userFacingValue = await treeNode.convertInternalValueToUserFacingValue(internalValue);

    return userFacingValue;
  }

  /**
   * Set a setting value server wide. This also wipes out any channel settings in the server
   * for the setting with the specified unique ID. User settings are unaffected.
   * @param {string} settingUniqueId
   * @param {string} serverId - The ID of the server where the setting is being set.
   * @param {string} newUserFacingValue - The user facing value of the new setting value.
   * @param {boolean} userIsServerAdmin - Whether or not the user is a server admin.
   * @returns {Settings~SettingUpdateResult}
   */
  async setServerWideSettingValue(settingUniqueId, serverId, newUserFacingValue, userIsServerAdmin) {
    return this.setSettingValue_(settingUniqueId, serverId, undefined, undefined, newUserFacingValue, userIsServerAdmin, SettingScope.SERVER);
  }

  /**
   * Set a setting value for a channel.
   * @param {string} settingUniqueId
   * @param {string} serverId - The ID of the server where the setting is being set.
   * @param {string} channelId - The ID of the channel where the setting is being set.
   * @param {string} newUserFacingValue - The user facing value of the new setting value.
   * @param {boolean} userIsServerAdmin - Whether or not the user is a server admin.
   * @returns {Settings~SettingUpdateResult} The result of the attempt to update the setting.
   */
  async setChannelSettingValue(settingUniqueId, serverId, channelId, newUserFacingValue, userIsServerAdmin) {
    return this.setSettingValue_(settingUniqueId, serverId, channelId, undefined, newUserFacingValue, userIsServerAdmin, SettingScope.CHANNEL);
  }

  /**
   * Reset all settings for a user.
   * @param {string} userId - The ID for the user to reset settings for.
   */
  async resetUserSettings(userId) {
    return this.persistence_.editDataForUser(userId, userData => {
      delete userData.settings;
      return userData;
    });
  }

  /**
   * Reset all settings (both server-wide and channel-specific settings) in a server.
   * @param {string} userId - The ID for the user to reset settings for.
   */
  async resetServerAndChannelSettings(serverId) {
    return this.persistence_.editDataForServer(serverId, serverData => {
      delete serverData.settings;
      return serverData;
    });
  }

  /**
   * Set a setting value for a user.
   * @param {string} settingUniqueId
   * @param {string} userId - The ID of the server to set the setting value for.
   * @param {string} newUserFacingValue - The user facing value of the new setting value.
   * @returns {Settings~SettingUpdateResult} The result of the attempt to update the setting.
   */
  async setUserSettingValue(settingUniqueId, userId, newUserFacingValue) {
    return this.setSettingValue_(settingUniqueId, undefined, undefined, userId, newUserFacingValue, false, SettingScope.USER);
  }

  async setSettingValue_(settingUniqueId, serverId, channelId, userId, newUserFacingValue, userIsServerAdmin, settingScope) {
    const treeNode = getTreeNodeForUniqueId(this.settingsTree_, settingUniqueId);
    const newSettingValidationResult = await this.validateNewSetting_(settingUniqueId, newUserFacingValue, userIsServerAdmin, settingScope);
    if (newSettingValidationResult.accepted) {
      await treeNode.updateSetting(
        this.persistence_,
        settingUniqueId,
        serverId,
        channelId,
        userId,
        newSettingValidationResult.newInternalValue,
        settingScope,
      );
      await onSettingChanged(
        treeNode,
        settingScope,
        serverId,
        channelId,
        userId,
        newSettingValidationResult,
      );
    }

    return newSettingValidationResult;
  }

  async validateNewSetting_(settingUniqueId, newUserFacingValue, userIsServerAdmin, settingScope) {
    const treeNode = getTreeNodeForUniqueId(this.settingsTree_, settingUniqueId);

    if (!treeNode) {
      return createUpdateRejectionResultNoSuchSetting(settingUniqueId);
    }
    if (settingScope !== SettingScope.USER && !userIsServerAdmin) {
      return createUpdateRejectionResultUserNotAdmin(treeNode);
    }
    if (!treeNode.serverSetting && settingScope === SettingScope.SERVER) {
      return createUpdateRejectionResultNotInServer(treeNode);
    }
    if (!treeNode.channelSetting && settingScope === SettingScope.CHANNEL) {
      return createUpdateRejectionResultNotInChannel(treeNode);
    }
    if (!treeNode.userSetting && settingScope === SettingScope.USER) {
      return createUpdateRejectionResultNotForUser(treeNode);
    }

    const newInternalValue = await treeNode.convertUserFacingValueToInternalValue(newUserFacingValue);
    const newValueIsValid = await treeNode.validateInternalValue(newInternalValue);

    if (!newValueIsValid) {
      return createUpdateRejectionResultValueInvalid(newUserFacingValue, treeNode);
    }

    return createUpdateAcceptedResult(newUserFacingValue, newInternalValue, treeNode);
  }
}

module.exports = Settings;
module.exports.UpdateRejectionReason = UpdateRejectionReason;
module.exports.SettingScope = SettingScope;