text: // filtered joke gets created out of the total-jokes array
  // filters can be applied with setter methods
  // final getter method returns one or multiple jokes that match all filters
  
  const AllJokes = require("./AllJokes");
  const parseJokes = require("../parseJokes");
  const languages = require("../languages");
  const tr = require("../translate");
  const jsl = require("svjsl");
  const settings = require("../../settings");
  
  
  /** @typedef {"nsfw"|"racist"|"sexist"|"religious"|"political"|"explicit"} BlacklistFlags */
  /** @typedef {"Any"|"Programming"|"Miscellaneous"|"Dark"} JokeCategory */
  
  
  jsl.unused(AllJokes);
  
  var _lastIDs = [];
  var _selectionAttempts = 0;
  
  class FilteredJoke
  {
      //#MARKER constructor
      /**
       * Constructs an object of type FilteredJoke
       * @param {AllJokes} allJokes 
       */
      constructor(allJokes)
      {
          if(jsl.isEmpty(allJokes))
              throw new Error(`Error while constructing new FilteredJoke object: parameter "allJokes" is empty`);
  
          this._allJokes = allJokes;
          this._filteredJokes = null;
  
          let idRangePerLang = {};
  
          Object.keys(parseJokes.jokeCountPerLang).forEach(lc => {
              idRangePerLang[lc] = [ 0, (parseJokes.jokeCountPerLang[lc] - 1) ];
          });
  
          /** Resolved category names (aliases are not allowed here) */
          this._allowedCategories = [
              settings.jokes.possible.anyCategoryName.toLowerCase(),
              ...settings.jokes.possible.categories.map(c => c.toLowerCase())
          ];
          this._allowedTypes = [...settings.jokes.possible.types];
          this._searchString = null;
          this._idRange = [0, (parseJokes.jokeCountPerLang[settings.languages.defaultLanguage] - 1)];
          this._idRangePerLang = idRangePerLang;
          this._flags = [];
          this._errors = [];
          this._lang = settings.languages.defaultLanguage;
          this._amount = 1;
          this._safeMode = false;
  
          if(!_lastIDs || !Array.isArray(_lastIDs))
              _lastIDs = [];
  
          return this;
      }
  
      //#MARKER categories
      /**
       * Sets the category / categories a joke can be from
       * @param {JokeCategory|JokeCategory[]} categories 
       * @returns {Boolean} Returns true if the category / categories were set successfully, else returns false
       */
      setAllowedCategories(categories)
      {
          if(!Array.isArray(categories))
              categories = new Array(categories);
  
          let allCategories = [
              settings.jokes.possible.anyCategoryName.toLowerCase(),
              ...settings.jokes.possible.categories.map(c => c.toLowerCase())
          ];
          let catsValid = [];
  
          if(typeof categories == "object" && categories.length != undefined)
              categories.forEach(cat => {
                  if(allCategories.includes(cat.toLowerCase()))
                      catsValid.push(true);
              });
  
          if(catsValid.length != categories.length)
          {
              this._errors.push("The joke category is invalid");
              return false;
          }
          
          if((typeof categories == "string" && categories.toLowerCase() == settings.jokes.possible.anyCategoryName.toLowerCase())
          || (typeof categories != "string" && categories.map(c => c.toLowerCase()).includes(settings.jokes.possible.anyCategoryName.toLowerCase())))
              categories = [...settings.jokes.possible.categories];
          
          this._allowedCategories = categories;
          return true;
      }
  
      /**
       * Returns the category / categories a joke can be in
       * @returns {JokeCategory[]}
       */
      getAllowedCategories()
      {
          return this._allowedCategories.map(c => c.toLowerCase());
      }
  
      //#MARKER type
      /**
       * Sets which types the joke(s) can be of
       * @param {"single"|"twopart"} type 
       * @returns {Boolean} Returns true if the type is valid and could be set, false if not
       */
      setAllowedType(type)
      {
          if(settings.jokes.possible.types.includes(type))
          {
              this._allowedTypes = [type];
              return true;
          }
          else
          {
              this._errors.push("The \"type\" parameter is invalid");
              return false;
          }
      }
  
      /**
       * Returns the allowed types a joke can be of
       * @returns {Array<"single"|"twopart">}
       */
      getAllowedTypes()
      {
          return this._allowedTypes
      }
  
      //#MARKER search string
      /**
       * Sets a string to serach for in jokes
       * @param {String} searchString Raw string to search for in the joke - URI components get decoded automatically
       * @returns {Boolean} Returns true if the search string is a valid string and could be set, false if not
       */
      setSearchString(searchString)
      {
          if(typeof searchString != "string")
          {
              this._errors.push("The \"contains\" parameter is invalid");
              return false;
          }
          
          try
          {
              this._searchString = decodeURIComponent(searchString);
              return true;
          }
          catch(err)
          {
              this._errors.push("The URI is malformatted or the \"contains\" parameter isn't correctly percent-encoded");
              return false;
          }
      }
  
      /**
       * Returns the set search string
       * @returns {(String|null)} Returns the search string if it is set, else returns null
       */
      getSearchString()
      {
          return this._searchString;
      }
  
      //#MARKER id
      /**
       * The IDs a joke can be of
       * @param {Number} start
       * @param {Number} [end] If this is not set, it will default to the same value the param `start` has
       * @param {String} [lang] Lang code
       * @returns {Boolean} Returns false if the parameter(s) is/are not of type `number`, else returns true
       */
      setIdRange(start, end = null, lang = null)
      {
          if(jsl.isEmpty(end))
              end = start;
          
          if(jsl.isEmpty(lang))
              lang = this.getLanguage() || settings.languages.defaultLanguage;
  
          if(isNaN(parseInt(start)) || isNaN(parseInt(end)) || typeof start != "number" || typeof end != "number" || jsl.isEmpty(start) || jsl.isEmpty(end))
          {
              this._errors.push("The \"idRange\" parameter values are not numbers");
              return false;
          }
  
          if(start < 0 || end > this._idRangePerLang[lang][1])
          {
              this._errors.push("The \"idRange\" parameter values are out of range");
              return false;
          }
          
          this._idRange = [start, end];
          return true;
      }
  
      /**
       * Returns the current ID range
       * @returns {Array<Number>} An array containing two numbers (index 0 = start ID, index 1 = end ID)
       */
      getIdRange()
      {
          return this._idRange;
      }
  
      //#MARKER flags
      /**
       * Sets the blacklist flags
       * @param {Array<BlacklistFlags>} flags 
       * @returns {Boolean} Returns true if the flags were set, false if they are invalid
       */
      setBlacklistFlags(flags)
      {
          let flagsInvalid = false;
          flags.forEach(flag => {
              if(!settings.jokes.possible.flags.includes(flag))
                  flagsInvalid = true;
          });
  
          if(flagsInvalid)
          {
              this._errors.push("The \"blacklistFlags\" parameter is invalid or contains one or more invalid flags");
              return false;
          }
          
          this._flags = flags;
          return true;
      }
  
      /**
       * Returns the set blacklist flags
       * @returns {Array<BlacklistFlags>}
       */
      getBlacklistFlags()
      {
          return this._flags;
      }
  
      //#MARKER language
      /**
       * Sets the language
       * @param {String} code 
       * @returns {Boolean} Returns true if the language was set, false if it is invalid
       */
      setLanguage(code)
      {
          if(languages.isValidLang(code) === true)
          {
              this._lang = code;
              return true;
          }
  
          return false;
      }
  
      /**
       * Returns the set language code
       * @returns {String}
       */
      getLanguage()
      {
          return this._lang || settings.languages.defaultLanguage;
      }
  
      //#MARKER safe mode
      /**
       * Sets the safe mode
       * @param {Boolean} safeModeEnabled 
       * @returns {Boolean} Returns the new value of the safe mode
       */
      setSafeMode(safeModeEnabled)
      {
          if(typeof safeModeEnabled == "boolean")
              this._safeMode = safeModeEnabled;
          else
              this._safeMode = false;
          
          return this._safeMode;
      }
  
      /**
       * Returns the value of the safe mode
       * @returns {Boolean}
       */
      getSafeMode()
      {
          return this._safeMode;
      }
  
      //#MARKER amount
      /**
       * Sets the amount of jokes
       * @param {Number} num 
       * @returns {Boolean|String} Returns true if the amount was set, string containing error if it is invalid
       */
      setAmount(num)
      {
          num = parseInt(num);
  
          if(isNaN(num) || num < 1 || num > settings.jokes.maxAmount)
              return `"num" parameter in FilteredJoke.setAmount() couldn't be resolved to an integer or it is less than 0 or greater than ${settings.jokes.maxAmount}`;
  
          this._amount = num;
          return true;
      }
  
      /**
       * Returns the set joke amount or `1` if not yet set
       * @returns {Number}
       */
      getAmount()
      {
          return this._amount || 1;
      }
  
      //#MARKER apply filters
      /**
       * Applies the previously set filters and modifies the `this._filteredJokes` property with the applied filters
       * @private
       * @param {String} lang
       * @returns {Promise}
       */
      _applyFilters(lang)
      {
          return new Promise((resolve, reject) => {
              try
              {
                  this._filteredJokes = [];
  
                  if(!lang)
                      lang = settings.languages.defaultLanguage;
  
                  this._allJokes.getJokeArray(lang).forEach(joke => {
                      // iterate over each joke, reading all set filters and thereby checking if it suits the request
                      // to deny a joke from being served, just return from this callback function
  
                      //#SECTION safe mode
                      if(this.getSafeMode() === true) // if safe mode is enabled
                      {
                          if(joke.safe !== true) // if joke is not safe, it's invalid
                              return;
                          
                          if(joke.category == "Dark") // if joke is in category "Dark", it's invalid
                              return;
                      }
                      
                      
  
                      //#SECTION id range
                      let idRange = this.getIdRange(lang);
                      if(joke.id < idRange[0] || joke.id > idRange[1]) // if the joke is not in the specified ID range, it's invalid
                          return;
  
                      //#SECTION categories
                      let cats = this.getAllowedCategories().map(c => c.toLowerCase());
  
                      if((typeof cats == "object" && !cats.includes(settings.jokes.possible.anyCategoryName.toLowerCase()))
                      || (typeof cats == "string" && cats != settings.jokes.possible.anyCategoryName.toLowerCase()))
                      {
                          if(!cats.includes(joke.category.toLowerCase())) // if possible categories don't contain the requested category, joke is invalid
                              return;
                      }
  
                      //#SECTION flags
                      let blFlags = this.getBlacklistFlags();
                      if(!jsl.isEmpty(blFlags))
                      {
                          let flagMatches = false;
                          Object.keys(joke.flags).forEach(flKey => {
                              if(blFlags.includes(flKey) && joke.flags[flKey] === true)
                                  flagMatches = true;
                          });
                          if(flagMatches) // joke has one or more of the set blacklist flags, joke is invalid
                              return;
                      }
                      
                      //#SECTION type
                      if(!this.getAllowedTypes().includes(joke.type)) // if joke type doesn't match the requested type(s), joke is invalid
                          return;
                      
                      //#SECTION search string
                      let searchMatches = false;
                      if(!jsl.isEmpty(this.getSearchString()))
                      {
                          if(joke.type == "single" && joke.joke.toLowerCase().includes(this.getSearchString()))
                              searchMatches = true;
                          else if (joke.type == "twopart" && (joke.setup + joke.delivery).toLowerCase().includes(this.getSearchString()))
                              searchMatches = true;
                      }
                      else searchMatches = true;
  
                      if(!searchMatches) // if the provided search string doesn't match the joke, the joke is invalid
                          return;
                      
                      //#SECTION language
                      let langCode = this.getLanguage();
                      if(languages.isValidLang(langCode) !== true)
                          return; // invalid lang code, joke is invalid
                      if(joke.lang.toLowerCase() != langCode.toLowerCase())
                          return; // lang code doesn't match so joke is invalid
                      
                      // Note: amount param is used in getJokes()
  
                      //#SECTION done
                      this._filteredJokes.push(joke); // joke is valid, push it to the array that gets passed in the resolve() just below
                  });
  
                  return resolve(this._filteredJokes);
              }
              catch(err)
              {
                  return reject(err);
              }
          });
      }
  
      //#MARKER get joke
      /**
       * Applies all filters and returns the final joke
       * @param {Number} [amount=1] The amount of jokes to return
       * @returns {Promise<Array<parseJokes.SingleJoke|parseJokes.TwopartJoke>>} Returns a promise containing an array, which in turn contains a single or multiple randomly selected joke/s that match/es the previously set filters. If the filters didn't match, rejects promise.
       */
      getJokes(amount = 1)
      {
          amount = parseInt(amount);
          if(isNaN(amount) || jsl.isEmpty(amount))
              amount = 1;
          
          return new Promise((resolve, reject) => {
              let retJokes = [];
              let multiSelectLastIDs = [];
  
              this._applyFilters(this._lang || settings.languages.defaultLanguage).then(filteredJokes => {
                  if(filteredJokes.length == 0 || typeof filteredJokes != "object")
                  {
                      if(this._errors && this._errors.length > 0)
                          return reject(this._errors);
                      else
                          return reject(tr(this.getLanguage(), "foundNoMatchingJokes"));
                  }
                  
                  if(!_lastIDs || !Array.isArray(_lastIDs))
                      _lastIDs = [];
                  
                  if(typeof _selectionAttempts != "number")
                      _selectionAttempts = 0;
  
                  /**
                   * @param {Array<parseJokes.SingleJoke|parseJokes.TwopartJoke>} jokes 
                   */
                  let selectRandomJoke = jokes => {
                      let idx = jsl.randRange(0, (jokes.length - 1));
                      let selectedJoke = jokes[idx];
  
                      if(jokes.length > settings.jokes.lastIDsMaxLength && _lastIDs.includes(selectedJoke.id))
                      {
                          if(_selectionAttempts > settings.jokes.jokeRandomizationAttempts)
                              return reject();
  
                          _selectionAttempts++;
  
                          jokes.splice(idx, 1); // remove joke that is already contained in _lastIDs
  
                          return selectRandomJoke(jokes);
                      }
                      else
                      {
                          _lastIDs.push(selectedJoke.id);
  
                          if(_lastIDs.length > settings.jokes.lastIDsMaxLength)
                              _lastIDs.shift();
  
                          _selectionAttempts = 0;
  
                          if(!multiSelectLastIDs.includes(selectedJoke.id))
                          {
                              multiSelectLastIDs.push(selectedJoke.id);
                              return selectedJoke;
                          }
                          else
                          {
                              if(_selectionAttempts > settings.jokes.jokeRandomizationAttempts)
                                  return reject();
  
                              _selectionAttempts++;
  
                              jokes.splice(idx, 1); // remove joke that is already contained in _lastIDs
  
                              return selectRandomJoke(jokes);
                          }
                      }
                  };
  
                  if(amount < filteredJokes.length)
                  {
                      for(let i = 0; i < amount; i++)
                      {
                          let rJoke = selectRandomJoke(filteredJokes);
                          if(rJoke != null)
                              retJokes.push(rJoke);
                      }
                  }
                  else retJokes = filteredJokes;
                  
                  // Sort jokes by ID
                  // retJokes.sort((a, b) => {
                  //     if(b.id > a.id)
                  //         return -1;
                  //     else
                  //         return 1;
                  // });
  
                  return resolve(retJokes);
              }).catch(err => {
                  return reject(err);
              });
          });
      }
  
      //#MARKER get all jokes
      /**
       * Applies all filters and returns an array of all jokes that are viable
       * @returns {Promise<Array<parseJokes.SingleJoke|parseJokes.TwopartJoke>>} Returns a promise containing a single, randomly selected joke that matches the previously set filters. If the filters didn't match, rejects promise.
       */
      getAllJokes()
      {
          return new Promise((resolve, reject) => {
              this._applyFilters(this._lang || settings.languages.defaultLanguage).then(filteredJokes => {
                  return resolve(filteredJokes);
              }).catch(err => {
                  return reject(err);
              });
          });
      }
  }
  
  module.exports = FilteredJoke;
  