/**
 * This class is responsible for indexing all documents available in the world and storing them in a word tree structure
 * that allows for fast searching.
 */
class DocumentIndex {
  constructor() {
    /**
     * A collection of WordTree structures for each document type.
     * @type {Record<string, WordTree>}
     */
    Object.defineProperty(this, "trees", {value: {}});

    /**
     * A reverse-lookup of a document's UUID to its parent node in the word tree.
     * @type {Record<string, StringTreeNode>}
     */
    Object.defineProperty(this, "uuids", {value: {}});
  }

  /**
   * While we are indexing, we store a Promise that resolves when the indexing is complete.
   * @type {Promise<void>|null}
   * @private
   */
  #ready = null;

  /* -------------------------------------------- */

  /**
   * Returns a Promise that resolves when the indexing process is complete.
   * @returns {Promise<void>|null}
   */
  get ready() {
    return this.#ready;
  }

  /* -------------------------------------------- */

  /**
   * Index all available documents in the world and store them in a word tree.
   * @returns {Promise<void>}
   */
  async index() {
    // Conclude any existing indexing.
    await this.#ready;
    const indexedCollections = CONST.WORLD_DOCUMENT_TYPES.filter(c => {
      const documentClass = getDocumentClass(c);
      return documentClass.metadata.indexed && documentClass.schema.has("name");
    });
    // TODO: Consider running this process in a web worker.
    const start = performance.now();
    return this.#ready = new Promise(resolve => {
      for ( const documentName of indexedCollections ) {
        this._indexWorldCollection(documentName);
      }

      for ( const pack of game.packs ) {
        if ( !indexedCollections.includes(pack.documentName) ) continue;
        this._indexCompendium(pack);
      }

      resolve();
      console.debug(`${vtt} | Document indexing complete in ${performance.now() - start}ms.`);
    });
  }

  /* -------------------------------------------- */

  /**
   * Return entries that match the given string prefix.
   * @param {string} prefix                     The prefix.
   * @param {object} [options]                  Additional options to configure behaviour.
   * @param {string[]} [options.documentTypes]  Optionally provide an array of document types. Only entries of that type
   *                                            will be searched for.
   * @param {number} [options.limit=10]         The maximum number of items per document type to retrieve. It is
   *                                            important to set this value as very short prefixes will naturally match
   *                                            large numbers of entries.
   * @param {StringTreeEntryFilter} [options.filterEntries]         A filter function to apply to each candidate entry.
   * @param {DOCUMENT_OWNERSHIP_LEVELS|string} [options.ownership]  Only return entries that the user meets this
   *                                                                ownership level for.
   * @returns {Record<string, WordTreeEntry[]>} A number of entries that have the given prefix, grouped by document
   *                                            type.
   */
  lookup(prefix, {limit=10, documentTypes=[], ownership, filterEntries}={}) {
    const types = documentTypes.length ? documentTypes : Object.keys(this.trees);
    if ( ownership !== undefined ) {
      const originalFilterEntries = filterEntries ?? (() => true);
      filterEntries = entry => {
        return originalFilterEntries(entry) && DocumentIndex.#filterEntryForOwnership(entry, ownership);
      }
    }
    const results = {};
    for ( const type of types ) {
      results[type] = [];
      const tree = this.trees[type];
      if ( !tree ) continue;
      results[type].push(...tree.lookup(prefix, { limit, filterEntries }));
    }
    return results;
  }

  /* -------------------------------------------- */

  /**
   * Add an entry to the index.
   * @param {Document} doc  The document entry.
   */
  addDocument(doc) {
    if ( doc.pack ) {
      if ( doc.isEmbedded ) return; // Only index primary documents inside compendium packs
      const pack = game.packs.get(doc.pack);
      const index = pack.index.get(doc.id);
      if ( index ) this._addLeaf(index, {pack});
    }
    else this._addLeaf(doc);
  }

  /* -------------------------------------------- */

  /**
   * Remove an entry from the index.
   * @param {Document} doc  The document entry.
   */
  removeDocument(doc) {
    const node = this.uuids[doc.uuid];
    if ( !node ) return;
    node[foundry.utils.StringTree.leaves].findSplice(e => e.uuid === doc.uuid);
    delete this.uuids[doc.uuid];
  }

  /* -------------------------------------------- */

  /**
   * Replace an entry in the index with an updated one.
   * @param {Document} doc  The document entry.
   */
  replaceDocument(doc) {
    this.removeDocument(doc);
    this.addDocument(doc);
  }

  /* -------------------------------------------- */

  /**
   * Add a leaf node to the word tree index.
   * @param {Document|object} doc                  The document or compendium index entry to add.
   * @param {object} [options]                     Additional information for indexing.
   * @param {CompendiumCollection} [options.pack]  The compendium that the index belongs to.
   * @protected
   */
  _addLeaf(doc, {pack}={}) {
    const entry = {entry: doc, documentName: doc.documentName, uuid: doc.uuid};
    if ( pack ) foundry.utils.mergeObject(entry, {
      documentName: pack.documentName,
      uuid: `Compendium.${pack.collection}.${doc._id}`,
      pack: pack.collection
    });
    const tree = this.trees[entry.documentName] ??= new foundry.utils.WordTree();
    this.uuids[entry.uuid] = tree.addLeaf(doc.name, entry);
  }

  /* -------------------------------------------- */

  /**
   * Aggregate the compendium index and add it to the word tree index.
   * @param {CompendiumCollection} pack  The compendium pack.
   * @protected
   */
  _indexCompendium(pack) {
    for ( const entry of pack.index ) {
      this._addLeaf(entry, {pack});
    }
  }

  /* -------------------------------------------- */

  /**
   * Add all of a parent document's embedded documents to the index.
   * @param {Document} parent  The parent document.
   * @protected
   */
  _indexEmbeddedDocuments(parent) {
    const embedded = parent.constructor.metadata.embedded;
    for ( const embeddedName of Object.keys(embedded) ) {
      if ( !CONFIG[embeddedName].documentClass.metadata.indexed ) continue;
      for ( const doc of parent[embedded[embeddedName]] ) {
        this._addLeaf(doc);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Aggregate all documents and embedded documents in a world collection and add them to the index.
   * @param {string} documentName  The name of the documents to index.
   * @protected
   */
  _indexWorldCollection(documentName) {
    const cls = CONFIG[documentName].documentClass;
    const collection = cls.metadata.collection;
    for ( const doc of game[collection] ) {
      this._addLeaf(doc);
      this._indexEmbeddedDocuments(doc);
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Check if the given entry meets the given ownership requirements.
   * @param {WordTreeEntry} entry                         The candidate entry.
   * @param {DOCUMENT_OWNERSHIP_LEVELS|string} ownership  The ownership.
   * @returns {boolean}
   */
  static #filterEntryForOwnership({ uuid, pack }, ownership) {
    if ( pack ) return game.packs.get(pack)?.testUserPermission(game.user, ownership);
    return fromUuidSync(uuid)?.testUserPermission(game.user, ownership);
  }
}
