LevelManager_LevelManager.js

'use strict'

const _ = require('lodash')
const Discord = require('discord.js')
const { Guild } = require('../Structures/Guild')
const { UserCard } = require('../Structures/UserCard')
const { Options } = require('./Options')
const { connect } = require('mongoose')
const { GuildsData } = require('../Model')
const { ValRnkAchv } = require('../Utils/ValRnkAchv')
const { ErrorCodes } = require('../Errors/ErrorCodes')
const { readdirSync } = require('node:fs')
const { RankBuilder } = require('../Structures/RankBuilder')
const { EventEmitter } = require('node:events')
const { GuildManager } = require('../Managers/GuildManager')
const { LevelManagerEvents } = require('../Interfaces')
const { AchievementBuilder } = require('../Structures/AchievementBuilder')
const { isNotPositiveInteger } = require('../Utils/ValidateInteger')
const { MissingValue, InvalidValue, EventMissingProperty } = require('../Errors/LME')

/** @typedef {import('../../typings').Rank} Rank */
/** @typedef {import('../../typings').Achievement} Achievement */
/** @typedef {import('../../typings').LevelManagerOptions} LevelManagerOptions */
/** @typedef {import('../../typings').UserEntry} UserEntry */
/** @typedef {import('../../typings').DatabaseData} DatabaseData */
/** @typedef {import('../../typings').LeaderboardOptions} LeaderboardOptions */

/**
 * The hub of the library, this class is responsible for managing the ranks, achievements, and users from database.
 *
 * If you want to override the function that calculate the xp to level up, then visit {@tutorial overrideXpFunction}.
 *
 * @extends {EventEmitter}
 */
class LevelManager extends EventEmitter {
  /** @type {Discord.Client} - Discord client */
  client
  /** @type {String} - MongoDB Uri */
  mongoUri
  /** @type {Function} - The function that calculate the xp to level up */
  xpFunction
  /** @type {Rank[]} - The default ranks when create a new guild entry */
  ranks
  /** @type {Achievement[]} - The default achievements when create a new guild entry */
  achievement
  /** @type {Number} - The max xp to level up */
  maxXpToLevelUp
  /** @type {Number} - The interval to save the data in the database */
  saveInterval
  /** @type {Boolean} - If the data will be saved automatically */
  autoSave
  /** @type {String} - The path to the events folder */
  eventsPath
  /** @type {GuildManager} - The guild manager */
  guilds

  /**
   * @param {Discord.Client} client - Discord client.
   * @param {LevelManagerOptions} options - Options for the level hub.
   *
   * @fires LevelManager#managerReady - When the manager is ready.
   *
   * @throws {MissingValue} - If some property of options is missing, or if options is undefined.
   * @throws {InvalidValue} - If some property of options is not the correct type.
   */
  constructor (client, options) {
    super()

    // Check if client exists and if is a discord client instance
    if (!client) throw new MissingValue(ErrorCodes.MissingArgument, 'client')

    if (!(client instanceof Discord.Client)) throw new InvalidValue(ErrorCodes.InvalidValue, 'Client', 'a Discord Client instance')

    this.client = client

    this.#checkOptions(options)

    // Set all default options
    this.ranks = options.ranks ?? Options.setDefaultRanks()

    this.xpFunction = options.xpFunction ?? function (level, xp) {
      return level * this.maxXpToLevelUp
    }

    this.achievements = options.achievements ?? Options.setDefaultAchievements()

    this.maxXpToLevelUp = options.maxXpToLevelUp ?? 2500

    this.saveInterval = options.saveInterval ?? 1000 * 60 * 60 * 3 // 3 hours

    this.autoSave = options.autoSave ?? true

    this.eventsPath = options.eventsPath ?? undefined

    this.mongoUri = options.mongoUri

    // Declare a guild manager
    this.guilds = new GuildManager(client, [])

    this.#init().then(() => {
      /**
       * Emitted when the manager is ready.
       * @event LevelManager#managerReady
       * @property {Discord.Client} client - Discord client.
       */
      this.emit(LevelManagerEvents.ManagerReady, this.client)
    })
  }

  /**
   * Check if options have the correct type.
   * @param {LevelManagerOptions} options - Options for the level hub.
   *
   * @throws {MissingValue} - If some property of options is missing, or if the parameter options is undefined.
   * @throws {InvalidValue} - If some property of options is not the correct type.
   * @private
   */
  #checkOptions (options) {
    if (!options) throw new MissingValue(ErrorCodes.MissingArgument, 'options')

    if (typeof options !== 'object') throw new InvalidValue(ErrorCodes.InvalidValue, 'options', 'an object')

    if (!options.mongoUri) throw new MissingValue(ErrorCodes.MissingProperty, 'mongoUri')

    if (typeof options.mongoUri !== 'string') throw new InvalidValue(ErrorCodes.InvalidValue, 'mongoUri', 'a string')

    if (options.xpFunction && typeof options.xpFunction !== 'function') throw new InvalidValue(ErrorCodes.InvalidValue, 'xpFunction', 'a function')

    if (options.ranks && !Array.isArray(options.ranks)) throw new InvalidValue(ErrorCodes.InvalidValue, 'ranks', 'an array')

    if (options.ranks && !options.ranks.length) throw new InvalidValue(ErrorCodes.InvalidValue, 'ranks', 'an array with at least one element')

    if (options.achievements && !Array.isArray(options.achievements)) throw new InvalidValue(ErrorCodes.InvalidValue, 'achievements', 'an array')

    if (options.achievements && !options.achievements.length) throw new InvalidValue(ErrorCodes.InvalidValue, 'achievements', 'an array with at least one element')

    if (options.maxXpToLevelUp && typeof options.maxXpToLevelUp !== 'number') throw new InvalidValue(ErrorCodes.InvalidValue, 'maxXpToLevelUp', 'a number')

    if (options.saveInterval && typeof options.saveInterval !== 'number') throw new InvalidValue(ErrorCodes.InvalidValue, 'saveInterval', 'a number')

    if (options.autoSave && typeof options.autoSave !== 'boolean') throw new InvalidValue(ErrorCodes.InvalidValue, 'autoSave', 'a boolean')

    if (options.eventsPath && typeof options.eventsPath !== 'string') throw new InvalidValue(ErrorCodes.InvalidValue, 'eventsPath', 'a string')

    if (options.ranks) this.#checkRanks(options.ranks)

    if (options.achievements) this.#checkAchievements(options.achievements)
  }

  /**
   * Check if ranks have the correct type and if have all properties.
   * @param {Rank[] | RankBuilder[]} ranks - Rank that will be checked.
   * @private
   */
  #checkRanks (ranks) {
    for (const rank of ranks) {
      if (typeof rank !== 'object' && !(rank instanceof RankBuilder)) throw new InvalidValue(ErrorCodes.InvalidValue, 'ranks', 'an array of object or instance of Rank.')

      ValRnkAchv.validateRank(rank)
    }
  }

  /**
   * Check if achievements have the correct type and if have all properties.
   * @param {Achievement[] | AchievementBuilder[]} achievements - Achievement that will be checked.
   * @private
   */
  #checkAchievements (achievements) {
    for (const achievement of achievements) {
      if (typeof achievement !== 'object' && !(achievement instanceof AchievementBuilder)) throw new InvalidValue(ErrorCodes.InvalidValue, 'achievements', 'an array of object or instance of Achievement.')

      ValRnkAchv.validateAchievement(achievement)
    }
  }

  /**
   * Initialize the manager, first load all events and start the auto save interval,
   * then load all data from the database.
   * @returns {Promise<void>}
   * @private
   */
  #init () {
    return new Promise((resolve, reject) => {
      if (this.eventsPath) this.#loadEvents()

      if (this.autoSave) this.#startAutoSave()

      this.#loadData().then(resolve).catch(reject)
    })
  }

  /**
   * Load all events from the events path.
   * @returns {Promise<void>}
   * @private
   */
  async #loadEvents () {
    const events = await readdirSync(this.eventsPath)
    const totalEvents = []

    // Loop through all events and load them
    for (const eventFile of events) {
      const event = require(`${this.eventsPath}/${eventFile}`)

      if (!event.eventType) throw new EventMissingProperty(ErrorCodes.EventMissingProperty, 'a eventType property')

      if (!event.run) throw new EventMissingProperty(ErrorCodes.EventMissingProperty, 'a run method')

      if (typeof event.run !== 'function') throw new InvalidValue(ErrorCodes.InvalidValue, 'run', 'a function')

      if (!Object.values(LevelManagerEvents).includes(event.eventType)) throw new InvalidValue(ErrorCodes.InvalidValue, 'eventType', 'a valid event type')

      totalEvents.push(event.eventType)

      this.on(event.eventType, event.run.bind(null, this))
    }

    console.table(totalEvents)
  }

  /**
   * Start the auto save interval.
   * @returns {void}
   * @private
   */
  #startAutoSave () {
    setInterval(async () => {
      await this.saveData()
    }, this.saveInterval)
  }

  /**
   * Load all guilds data from the database.
   * @returns {Promise<void>}
   * @private
   */
  async #loadData () {
    await connect(this.mongoUri, {
      useNewUrlParser: true,
      useUnifiedTopology: true
    }).then(async () => {
      /** @type {DatabaseData[]} */
      const bunch = await GuildsData.find({})

      const guilds = []

      // For each guild data create a new guild instance and push it to the guilds array.
      for (const chunk of bunch) {
        const guild = new Guild(this.client, chunk)

        guilds.push(guild)
      }

      this.guilds = new GuildManager(this.client, guilds)
    })
      .catch(console.log)
  }

  /**
   * Add a new guild entry to the cache and database.
   * @param {Discord.Guild} guild
   * @returns {Promise<Guild>}
   * @private
   */
  async #addGuild (guild) {
    const guildData = new Guild(this.client, {
      guildId: guild.id,
      guildName: guild.name ?? 'Unknown',
      achievements: this.achievements,
      ranks: this.ranks,
      users: []
    })

    this.guilds.cache.set(guildData.guildId, guild)

    /*  const document = await new GuildsData(guildData.toJSON())

    await document.save().catch(console.log) */

    return guildData
  }

  /**
   * Add a new user entry to the cache and database.
   * @param {Discord.GuildMember} member - The member that will be added.
   * @param {Guild} guild - The guild where the member will be added.
   * @returns {Promise<UserCard>}
   * @private
   */
  async #addUser (member, guild) {
    /** @type {UserEntry} */
    const data = {
      id: member.id,
      username: member.user.username,
      level: 0,
      xp: 0,
      maxXpToLevelUp: this.maxXpToLevelUp,
      messages: [],
      achievements: this.achievements,
      rank: this.ranks[0]
    }
    const user = new UserCard(this.client, data)

    guild.users.cache.set(member.id, user)

    // await GuildsData.findOneAndUpdate({ guildId: guild.guildId }, { $push: { users: UserCard } }).catch(console.log)

    return user
  }

  /**
   * Check if member and guildId are valid.
   * @param {Discord.GuildMember} member - The member that will checked.
   * @param {Discord.Snowflake} guildId - The guild that will checked.
   *
   * @throws {MissingValue} - If member is missing.
   * @throws {InvalidValue} - If member or guildId is invalid.
   */
  #filter (member, guildId) {
    if (!member) throw new MissingValue(ErrorCodes.MissingArgument, 'member')

    if (!(member instanceof Discord.GuildMember)) throw new InvalidValue(ErrorCodes.InvalidValue, 'member', 'a GuildMember')

    if (typeof guildId !== 'string') throw new InvalidValue(ErrorCodes.InvalidValue, 'guildId', 'a string')

    if (guildId !== 'global' && !/^[0-9]+$/.test(guildId)) throw new InvalidValue(ErrorCodes.InvalidValue, 'guildId', 'a SnowFlake')
  }

  /**
   * Update the rank of a user.
   * @param {UserCard} user - The user that will have a update in the rank.
   * @param {Guild} guild - The guild where the user is.
   * @param {Number} offset - The offset of the rank (+1 if the user level up, -1 if the user level down).
   * @return {Rank}
   * @private
   */
  #updateRankUser (user, guild, offset) {
    const rankIndex = guild.ranks.cache.findKey(rank => rank.priority === user.rank.priority + offset)

    // If the rankIndex is undefined it means that the user is already in the highest or lowest rank.
    if (!rankIndex) return

    const rank = guild.ranks.cache.get(rankIndex)

    user.rank = rank
    return rank
  }

  /**
   * Save all of guilds data to your database.
   * @returns {Promise<void>}
   */
  async saveData () {
    for (const guild of this.guilds.cache.values()) {
      // await GuildsData.findOneAndUpdate({ guildId: guild.guildId }, guild.toJSON())
    }
  }

  /**
   * Add a certain amount of xp to a member of a guild, there is no need to create a user entry, it will be created automatically if it doesn't exist.
   * @param {Discord.GuildMember} member - The member that will receive the xp.
   * @param {Number} xp - The amount of xp that will be added.
   * @param {Discord.Guild} [guild] - The guild where the member is.
   * @returns {Promise<boolean>} - Return true if the member has leveled up.
   *
   * @fires LevelManager#levelUp - When a member level up.
   * @fires LevelManager#xpAdded - When a member receives xp.
   *
   * @throws {MissingValue} - If member or xp is missing.
   * @throws {InvalidValue} - If member or xp is invalid.
   */
  async addXp (member, xp, guild = { id: 'global', name: 'global' }) {
    const { id: guildId } = guild

    this.#filter(member, guildId)

    if (!xp) throw new MissingValue(ErrorCodes.MissingArgument, 'xp')

    if (isNotPositiveInteger(xp)) throw new InvalidValue(ErrorCodes.InvalidValue, 'xp', 'a positive integer')

    let guildObject = this.guilds.cache.get(guildId)
    if (!guildObject) guildObject = await this.#addGuild(guild)

    let user = guildObject.users.cache.get(member.id)
    if (!user) user = await this.#addUser(member, guildObject)

    // This package uses integer numbers, so we need to truncate the xp to a integer.
    user.xp += Math.floor(xp)

    /**
     * Emits when a member receives xp.
     * @event LevelManager#xpAdded
     * @property {UserCard} user - The user that received the xp.
     * @property {Guild} guildObject - The guildObject where the user is.
     * @property {Number} xp - The amount of xp that the user received.
     */
    this.emit(LevelManagerEvents.XpAdded, user, guildObject, xp)

    if (user.xp >= user.maxXpToLevelUp) {
      user.level++
      user.xp = Math.floor(user.xp - user.maxXpToLevelUp)

      this.#updateRankUser(user, guildObject, 1)

      user.maxXpToLevelUp = this.xpFunction(user.level, user.xp)

      /**
       * Emits when a member has leveled up.
       * @event LevelManager#levelUp
       * @property {UserCard} user - The user that leveled up.
       * @property {Guild} guildObject - The guildObject where the user is.
       */
      this.emit(LevelManagerEvents.LevelUp, user, guildObject)

      return true
    }

    return false
  }

  /**
   * Manually level up a member.
   * @param {Discord.GuildMember} member - The member that will be leveled up.
   * @param {Discord.Snowflake} [guildId='global'] - The guild where the member is.
   * @returns {Promise<UserCard>} - Return the user data of the member.
   *
   * @fires LevelManager#levelUp - When a member level up.
   *
   * @throws {MissingValue} - If member is missing.
   * @throws {InvalidValue} - If member or guildId is invalid.
   */
  async levelUp (member, guildId = 'global') {
    this.#filter(member, guildId)

    let guild = this.guilds.cache.get(guildId)
    if (!guild) guild = await this.#addGuild(guildId)

    let user = guild.users.cache.get(member.id)
    if (!user) user = await this.#addUser(member, guild)

    user.level++
    user.xp = 0

    this.#updateRankUser(user, guild, 1)

    user.maxXpToLevelUp = this.xpFunction(user.level, user.xp)

    this.emit(LevelManagerEvents.LevelUp, user, guild)

    return user
  }

  /**
   * Decrease the level of a member.
   * @param {Discord.GuildMember} member - The member that will be leveled down.
   * @param {Discord.Snowflake} [guildId='global'] - The guild where the member is.
   * @returns {Promise<UserCard>} - Return the user data of the member.
   *
   * @fires LevelManager#levelDown - When a member level down.
   *
   * @throws {MissingValue} - If member is missing.
   * @throws {InvalidValue} - If member or guildId is invalid.
   */
  async removeLevel (member, guildId = 'global') {
    this.#filter(member, guildId)

    let guild = this.guilds.cache.get(guildId)
    if (!guild) guild = await this.#addGuild(guildId)

    let user = guild.users.cache.get(member.id)
    if (!user) user = await this.#addUser(member, guild)

    // If user level is 0, we don't need to do anything.
    if (user.level <= 0) return user

    user.level--

    this.#updateRankUser(user, guild, -1)

    user.maxXpToLevelUp = this.xpFunction(user.level, user.xp)

    /**
     * Emits when a member has leveled down.
     * @event LevelManager#levelDown
     * @property {UserCard} user - The user that leveled down.
     * @property {Guild} guild - The guild where the user is.
     */
    this.emit(LevelManagerEvents.LevelDown, user, guild)

    return user
  }

  /**
   * Manually rank up a member.
   * @param {Discord.GuildMember} member - The member that will be ranked up.
   * @param {Discord.Snowflake} [guildId='global'] - The guild where the member is.
   * @returns {Promise<UserCard>} - Return the user data of the member.
   *
   * @fires LevelManager#rankUp - When a member rank up.
   *
   * @throws {MissingValue} - If member is missing.
   * @throws {InvalidValue} - If member or guildId is invalid.
   */
  async rankUp (member, guildId = 'global') {
    this.#filter(member, guildId)

    let guild = this.guilds.cache.get(guildId)
    if (!guild) guild = await this.#addGuild(guildId)

    let user = guild.users.cache.get(member.id)
    if (!user) user = await this.#addUser(member, guild)

    const rank = this.#updateRankUser(user, guild, 1)

    /**
     * Emits when a member has ranked up.
     * @event LevelManager#rankUp
     * @property {UserCard} user - The user that ranked up.
     * @property {Guild} guild - The guild where the user is.
     * @property {Rank} rank - The new rank of the user.
     */
    this.emit(LevelManagerEvents.RankUp, user, guild, rank)

    return user
  }

  /**
   * Manually rank down a member.
   * @param {Discord.GuildMember} member - The member that will be ranked down.
   * @param {Discord.Snowflake} [guildId='global'] - The guild where the member is.
   * @returns {Promise<UserCard>} - Return the user data of the member.
   *
   * @fires LevelManager#rankDown - When a member rank down.
   *
   * @throws {MissingValue} - If member is missing.
   * @throws {InvalidValue} - If member or guildId is invalid.
   */
  async degradeRank (member, guildId = 'global') {
    this.#filter(member, guildId)

    let guild = this.guilds.cache.get(guildId)
    if (!guild) guild = await this.#addGuild(guildId)

    let user = guild.users.cache.get(member.id)
    if (!user) user = await this.#addUser(member, guild)

    const rank = this.#updateRankUser(user, guild, -1)

    /**
     * Emits when a member has ranked down.
     * @event LevelManager#rankDown
     * @property {UserCard} user - The user that ranked down.
     * @property {Guild} guild - The guild where the user is.
     * @property {Rank} rank - The new rank of the user.
     */
    this.emit(LevelManagerEvents.RankDown, user, guild, rank)

    return user
  }

  /**
   * Create an array of the top members of the guild (you can put a limit).
   * @param {LeaderboardOptions} [guildId={ guildId: 'global', limit: 10 }] - The guild where the leaderboard will be.
   * @returns {UserCard[]} - Return an array of the top members.
   *
   * @throws {MissingValue} - If guildId is missing.
   * @throws {InvalidValue} - If guildId or limit is invalid.
   */
  leaderboard ({ guildId = 'global', limit = 10 } = { guildId: 'global', limit: 10 }) {
    if (typeof guildId !== 'string') throw new InvalidValue(ErrorCodes.InvalidValue, 'guildId', 'a string')

    if (guildId !== 'global' && !/^[0-9]+$/.test(guildId)) throw new InvalidValue(ErrorCodes.InvalidValue, 'guildId', 'a SnowFlake')

    if (isNotPositiveInteger(limit)) throw new InvalidValue(ErrorCodes.InvalidValue, 'limit', 'a positive integer')

    const guild = this.guilds.cache.get(guildId)
    if (!guild) return []

    const array = guild.users.cache.map(u => u).sort((a, b) => b.level - a.level)

    const users = array.slice(0, array.length < limit ? array.length : limit)

    return _.cloneDeep(users)
  }
}

module.exports = { LevelManager }