/**
 * @typedef {ApplicationOptions} ChatLogOptions
 * @property {boolean} [stream]  Is this chat log being rendered as part of the stream view?
 */

/**
 * The sidebar directory which organizes and displays world-level ChatMessage documents.
 * @extends {SidebarTab}
 * @see {Sidebar}
 * @param {ChatLogOptions} [options]  Application configuration options.
 */
class ChatLog extends SidebarTab {
  constructor(options) {
    super(options);

    /**
     * Track any pending text which the user has submitted in the chat log textarea
     * @type {string}
     * @private
     */
    this._pendingText = "";

    /**
     * Track the history of the past 5 sent messages which can be accessed using the arrow keys
     * @type {object[]}
     * @private
     */
    this._sentMessages = [];

    /**
     * Track which remembered message is being currently displayed to cycle properly
     * @type {number}
     * @private
     */
    this._sentMessageIndex = -1;

    /**
     * Track the time when the last message was sent to avoid flooding notifications
     * @type {number}
     * @private
     */
    this._lastMessageTime = 0;

    /**
     * Track the id of the last message displayed in the log
     * @type {string|null}
     * @private
     */
    this._lastId = null;

    /**
     * Track the last received message which included the user as a whisper recipient.
     * @type {ChatMessage|null}
     * @private
     */
    this._lastWhisper = null;

    /**
     * A reference to the chat text entry bound key method
     * @type {Function|null}
     * @private
     */
    this._onChatKeyDownBinding = null;

    // Update timestamps every 15 seconds
    setInterval(this.updateTimestamps.bind(this), 1000 * 15);
  }

  /**
   * A flag for whether the chat log is currently scrolled to the bottom
   * @type {boolean}
   */
  #isAtBottom = true;

  /**
   * A cache of the Jump to Bottom element
   */
  #jumpToBottomElement;

  /**
   * A semaphore to queue rendering of Chat Messages.
   * @type {Semaphore}
   */
  #renderingQueue = new foundry.utils.Semaphore(1);

  /**
   * Currently rendering the next batch?
   * @type {boolean}
   */
  #renderingBatch = false;

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

  /**
   * Returns if the chat log is currently scrolled to the bottom
   * @returns {boolean}
   */
  get isAtBottom() {
    return this.#isAtBottom;
  }

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

  /**
   * @override
   * @returns {ChatLogOptions}
   */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "chat",
      template: "templates/sidebar/chat-log.html",
      title: game.i18n.localize("CHAT.Title"),
      stream: false,
      scrollY: ["#chat-log"]
    });
  }

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

  /**
   * An enumeration of regular expression patterns used to match chat messages.
   * @enum {RegExp}
   */
  static MESSAGE_PATTERNS = (() => {
    const dice = "([^#]+)(?:#(.*))?";       // Dice expression with appended flavor text
    const any = "([^]*)";                   // Any character, including new lines
    return {
      roll: new RegExp(`^(\\/r(?:oll)? )${dice}$`, "i"),                   // Regular rolls: /r or /roll
      gmroll: new RegExp(`^(\\/gmr(?:oll)? )${dice}$`, "i"),               // GM rolls: /gmr or /gmroll
      blindroll: new RegExp(`^(\\/b(?:lind)?r(?:oll)? )${dice}$`, "i"),    // Blind rolls: /br or /blindroll
      selfroll: new RegExp(`^(\\/s(?:elf)?r(?:oll)? )${dice}$`, "i"),      // Self rolls: /sr or /selfroll
      publicroll: new RegExp(`^(\\/p(?:ublic)?r(?:oll)? )${dice}$`, "i"),  // Public rolls: /pr or /publicroll
      ic: new RegExp(`^(/ic )${any}`, "i"),
      ooc: new RegExp(`^(/ooc )${any}`, "i"),
      emote: new RegExp(`^(/(?:em(?:ote)?|me) )${any}`, "i"),
      whisper: new RegExp(/^(\/w(?:hisper)?\s)(\[(?:[^\]]+)\]|(?:[^\s]+))\s*([^]*)/, "i"),
      reply: new RegExp(`^(/reply )${any}`, "i"),
      gm: new RegExp(`^(/gm )${any}`, "i"),
      players: new RegExp(`^(/players )${any}`, "i"),
      macro: new RegExp(`^(\\/m(?:acro)? )${any}`, "i"),
      invalid: /^(\/[^\s]+)/ // Any other message starting with a slash command is invalid
    };
  })();

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

  /**
   * The set of commands that can be processed over multiple lines.
   * @type {Set<string>}
   */
  static MULTILINE_COMMANDS = new Set(["roll", "gmroll", "blindroll", "selfroll", "publicroll"]);

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

  /**
   * A reference to the Messages collection that the chat log displays
   * @type {Messages}
   */
  get collection() {
    return game.messages;
  }

  /* -------------------------------------------- */
  /*  Application Rendering                       */
  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    const context = await super.getData(options);
    return foundry.utils.mergeObject(context, {
      rollMode: game.settings.get("core", "rollMode"),
      rollModes: Object.entries(CONFIG.Dice.rollModes).map(([k, v]) => ({
        group: "CHAT.RollDefault",
        value: k,
        label: v
      })),
      isStream: !!this.options.stream
    });
  }

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

  /** @inheritdoc */
  async _render(force, options) {
    if ( this.rendered ) return; // Never re-render the Chat Log itself, only its contents
    await super._render(force, options);
    return this.scrollBottom({waitImages: true});
  }

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

  /** @inheritdoc */
  async _renderInner(data) {
    const html = await super._renderInner(data);
    await this._renderBatch(html, CONFIG.ChatMessage.batchSize);
    return html;
  }

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

  /**
   * Render a batch of additional messages, prepending them to the top of the log
   * @param {jQuery} html     The rendered jQuery HTML object
   * @param {number} size     The batch size to include
   * @returns {Promise<void>}
   * @private
   */
  async _renderBatch(html, size) {
    if ( this.#renderingBatch ) return;
    this.#renderingBatch = true;
    return this.#renderingQueue.add(async () => {
      const messages = this.collection.contents;
      const log = html.find("#chat-log, #chat-log-popout");

      // Get the index of the last rendered message
      let lastIdx = messages.findIndex(m => m.id === this._lastId);
      lastIdx = lastIdx !== -1 ? lastIdx : messages.length;

      // Get the next batch to render
      let targetIdx = Math.max(lastIdx - size, 0);
      let m = null;
      if ( lastIdx !== 0 ) {
        let html = [];
        for ( let i=targetIdx; i<lastIdx; i++) {
          m = messages[i];
          if (!m.visible) continue;
          m.logged = true;
          try {
            html.push(await m.getHTML());
          } catch(err) {
            err.message = `Chat message ${m.id} failed to render: ${err})`;
            console.error(err);
          }
        }

        // Prepend the HTML
        log.prepend(html);
        this._lastId = messages[targetIdx].id;
        this.#renderingBatch = false;
      }
    });
  }

  /* -------------------------------------------- */
  /*  Chat Sidebar Methods                        */
  /* -------------------------------------------- */

  /**
   * Delete a single message from the chat log
   * @param {string} messageId    The ChatMessage document to remove from the log
   * @param {boolean} [deleteAll] Is this part of a flush operation to delete all messages?
   */
  deleteMessage(messageId, {deleteAll=false}={}) {
    return this.#renderingQueue.add(async () => {

      // Get the chat message being removed from the log
      const message = game.messages.get(messageId, {strict: false});
      if ( message ) message.logged = false;

      // Get the current HTML element for the message
      let li = this.element.find(`.message[data-message-id="${messageId}"]`);
      if ( !li.length ) return;

      // Update the last index
      if ( deleteAll ) {
        this._lastId = null;
      } else if ( messageId === this._lastId ) {
        const next = li[0].nextElementSibling;
        this._lastId = next ? next.dataset.messageId : null;
      }

      // Remove the deleted message
      li.slideUp(100, () => {
        li.remove();
        this._onScrollLog();
      });

      // Delete from popout tab
      if ( this._popout ) this._popout.deleteMessage(messageId, {deleteAll});
      if ( this.popOut ) this.setPosition();
    });
  }

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

  /**
   * Trigger a notification that alerts the user visually and audibly that a new chat log message has been posted
   * @param {ChatMessage} message         The message generating a notification
   */
  notify(message) {
    this._lastMessageTime = Date.now();
    if ( !this.rendered ) return;

    // Display the chat notification icon and remove it 3 seconds later
    let icon = $("#chat-notification");
    if ( icon.is(":hidden") ) icon.fadeIn(100);
    setTimeout(() => {
      if ( (Date.now() - this._lastMessageTime > 3000) && icon.is(":visible") ) icon.fadeOut(100);
    }, 3001);

    // Play a notification sound effect
    if ( message.sound ) game.audio.play(message.sound, {context: game.audio.interface});
  }

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

  /**
   * Parse a chat string to identify the chat command (if any) which was used
   * @param {string} message    The message to match
   * @returns {string[]}        The identified command and regex match
   */
  static parse(message) {
    for ( const [rule, rgx] of Object.entries(this.MESSAGE_PATTERNS) ) {

      // For multi-line matches, the first line must match
      if ( this.MULTILINE_COMMANDS.has(rule) ) {
        const lines = message.split("\n");
        if ( rgx.test(lines[0]) ) return [rule, lines.map(l => l.match(rgx))];
      }

      // For single-line matches, match directly
      else {
        const match = message.match(rgx);
        if ( match ) return [rule, match];
      }
    }
    return ["none", [message, "", message]];
  }

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

  /**
   * Post a single chat message to the log
   * @param {ChatMessage} message   A ChatMessage document instance to post to the log
   * @param {object} [options={}]   Additional options for how the message is posted to the log
   * @param {string} [options.before] An existing message ID to append the message before, by default the new message is
   *                                  appended to the end of the log.
   * @param {boolean} [options.notify] Trigger a notification which shows the log as having a new unread message.
   * @returns {Promise<void>}       A Promise which resolves once the message is posted
   */
  async postOne(message, {before, notify=false}={}) {
    if ( !message.visible ) return;
    return this.#renderingQueue.add(async () => {
      message.logged = true;

      // Track internal flags
      if ( !this._lastId ) this._lastId = message.id; // Ensure that new messages don't result in batched scrolling
      if ( (message.whisper || []).includes(game.user.id) && !message.isRoll ) {
        this._lastWhisper = message;
      }

      // Render the message to the log
      const html = await message.getHTML();
      const log = this.element.find("#chat-log");

      // Append the message after some other one
      const existing = before ? this.element.find(`.message[data-message-id="${before}"]`) : [];
      if ( existing.length ) existing.before(html);

      // Otherwise, append the message to the bottom of the log
      else {
        log.append(html);
        if ( this.isAtBottom || (message.author._id === game.user._id) ) this.scrollBottom({waitImages: true});
      }

      // Post notification
      if ( notify ) this.notify(message);

      // Update popout tab
      if ( this._popout ) await this._popout.postOne(message, {before, notify: false});
      if ( this.popOut ) this.setPosition();
    });
  }

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

  /**
   * Scroll the chat log to the bottom
   * @param {object} [options]
   * @param {boolean} [options.popout=false]                 If a popout exists, scroll it to the bottom too.
   * @param {boolean} [options.waitImages=false]             Wait for any images embedded in the chat log to load first
   *                                                         before scrolling?
   * @param {ScrollIntoViewOptions} [options.scrollOptions]  Options to configure scrolling behaviour.
   */
  async scrollBottom({popout=false, waitImages=false, scrollOptions={}}={}) {
    if ( !this.rendered ) return;
    if ( waitImages ) await this._waitForImages();
    const log = this.element[0].querySelector("#chat-log");
    log.lastElementChild?.scrollIntoView(scrollOptions);
    if ( popout ) this._popout?.scrollBottom({waitImages, scrollOptions});
  }

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

  /**
   * Update the content of a previously posted message after its data has been replaced
   * @param {ChatMessage} message   The ChatMessage instance to update
   * @param {boolean} notify        Trigger a notification which shows the log as having a new unread message
   */
  async updateMessage(message, notify=false) {
    let li = this.element.find(`.message[data-message-id="${message.id}"]`);
    if ( li.length ) {
      const html = await message.getHTML();
      li.replaceWith(html);
    }

    // Add a newly visible message to the log
    else {
      const messages = game.messages.contents;
      const messageIndex = messages.findIndex(m => m === message);
      let nextMessage;
      for ( let i = messageIndex + 1; i < messages.length; i++ ) {
        if ( messages[i].visible ) {
          nextMessage = messages[i];
          break;
        }
      }
      await this.postOne(message, {before: nextMessage?.id, notify: false});
    }

    // Post notification of update
    if ( notify ) this.notify(message);

    // Update popout tab
    if ( this._popout ) await this._popout.updateMessage(message, false);
    if ( this.popOut ) this.setPosition();
  }

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

  /**
   * Update the displayed timestamps for every displayed message in the chat log.
   * Timestamps are displayed in a humanized "timesince" format.
   */
  updateTimestamps() {
    const messages = this.element.find("#chat-log .message");
    for ( let li of messages ) {
      const message = game.messages.get(li.dataset.messageId);
      if ( !message?.timestamp ) return;
      const stamp = li.querySelector(".message-timestamp");
      if (stamp) stamp.textContent = foundry.utils.timeSince(message.timestamp);
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers
  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {

    // Load new messages on scroll
    html.find("#chat-log").scroll(this._onScrollLog.bind(this));

    // Chat message entry
    this._onChatKeyDownBinding = this._onChatKeyDown.bind(this);
    html.find("#chat-message").keydown(this._onChatKeyDownBinding);

    // Expand dice roll tooltips
    html.on("click", ".dice-roll", this._onDiceRollClick.bind(this));

    // Modify Roll Type
    html.find('select[name="rollMode"]').change(this._onChangeRollMode.bind(this));

    // Single Message Delete
    html.on("click", "a.message-delete", this._onDeleteMessage.bind(this));

    // Flush log
    html.find("a.chat-flush").click(this._onFlushLog.bind(this));

    // Export log
    html.find("a.export-log").click(this._onExportLog.bind(this));

    // Jump to Bottom
    html.find(".jump-to-bottom > a").click(() => this.scrollBottom());

    // Content Link Dragging
    html[0].addEventListener("drop", ChatLog._onDropTextAreaData);

    // Chat Entry context menu
    this._contextMenu(html);
  }

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

  /**
   * Handle dropping of transferred data onto the chat editor
   * @param {DragEvent} event     The originating drop event which triggered the data transfer
   * @private
   */
  static async _onDropTextAreaData(event) {
    event.preventDefault();
    const textarea = event.target;

    // Drop cross-linked content
    const eventData = TextEditor.getDragEventData(event);
    const link = await TextEditor.getContentLink(eventData);
    if ( link ) textarea.value += link;

    // Record pending text
    this._pendingText = textarea.value;
  }

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

  /**
   * Prepare the data object of chat message data depending on the type of message being posted
   * @param {string} message         The original string of the message content
   * @param {object} [options]       Additional options
   * @param {ChatSpeakerData} [options.speaker]   The speaker data
   * @returns {Promise<Object|void>} The prepared chat data object, or void if we were executing a macro instead
   */
  async processMessage(message, {speaker}={}) {
    message = message.trim();
    if ( !message ) return;
    const cls = ChatMessage.implementation;

    // Set up basic chat data
    const chatData = {
      user: game.user.id,
      speaker: speaker ?? cls.getSpeaker()
    };

    if ( Hooks.call("chatMessage", this, message, chatData) === false ) return;

    // Parse the message to determine the matching handler
    let [command, match] = this.constructor.parse(message);

    // Special handlers for no command
    if ( command === "invalid" ) throw new Error(game.i18n.format("CHAT.InvalidCommand", {command: match[1]}));
    else if ( command === "none" ) command = chatData.speaker.token ? "ic" : "ooc";

    // Process message data based on the identified command type
    const createOptions = {};
    switch (command) {
      case "roll": case "gmroll": case "blindroll": case "selfroll": case "publicroll":
        await this._processDiceCommand(command, match, chatData, createOptions);
        break;
      case "whisper": case "reply": case "gm": case "players":
        this._processWhisperCommand(command, match, chatData, createOptions);
        break;
      case "ic": case "emote": case "ooc":
        this._processChatCommand(command, match, chatData, createOptions);
        break;
      case "macro":
        this._processMacroCommand(command, match);
        return;
    }

    // Create the message using provided data and options
    return cls.create(chatData, createOptions);
  }

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

  /**
   * Process messages which are posted using a dice-roll command
   * @param {string} command          The chat command type
   * @param {RegExpMatchArray[]} matches Multi-line matched roll expressions
   * @param {Object} chatData         The initial chat data
   * @param {Object} createOptions    Options used to create the message
   * @private
   */
  async _processDiceCommand(command, matches, chatData, createOptions) {
    const actor = ChatMessage.getSpeakerActor(chatData.speaker) || game.user.character;
    const rollData = actor ? actor.getRollData() : {};
    const rolls = [];
    const rollMode = command === "roll" ? game.settings.get("core", "rollMode") : command;
    for ( const match of matches ) {
      if ( !match ) continue;
      const [formula, flavor] = match.slice(2, 4);
      if ( flavor && !chatData.flavor ) chatData.flavor = flavor;
      const roll = Roll.create(formula, rollData);
      await roll.evaluate({allowInteractive: rollMode !== CONST.DICE_ROLL_MODES.BLIND});
      rolls.push(roll);
    }
    chatData.rolls = rolls;
    chatData.sound = CONFIG.sounds.dice;
    chatData.content = rolls.reduce((t, r) => t + r.total, 0);
    createOptions.rollMode = rollMode;
  }

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

  /**
   * Process messages which are posted using a chat whisper command
   * @param {string} command          The chat command type
   * @param {RegExpMatchArray} match  The matched RegExp expressions
   * @param {Object} chatData         The initial chat data
   * @param {Object} createOptions    Options used to create the message
   * @private
   */
  _processWhisperCommand(command, match, chatData, createOptions) {
    delete chatData.speaker;

    // Determine the recipient users
    let users = [];
    let message= "";
    switch ( command ) {
      case "whisper":
        message = match[3];
        const names = match[2].replace(/[\[\]]/g, "").split(",").map(n => n.trim());
        users = names.reduce((arr, n) => arr.concat(ChatMessage.getWhisperRecipients(n)), []);
        break;
      case "reply":
        message = match[2];
        const w = this._lastWhisper;
        if ( w ) {
          const group = new Set(w.whisper);
          group.delete(game.user.id);
          group.add(w.author.id);
          users = Array.from(group).map(id => game.users.get(id));
        }
        break;
      case "gm":
        message = match[2];
        users = ChatMessage.getWhisperRecipients("gm");
        break;
      case "players":
        message = match[2];
        users = ChatMessage.getWhisperRecipients("players");
        break;
    }

    // Add line break elements
    message = message.replace(/\n/g, "<br>");

    // Ensure we have valid whisper targets
    if ( !users.length ) throw new Error(game.i18n.localize("ERROR.NoTargetUsersForWhisper"));
    if ( users.some(u => !u.isGM) && !game.user.can("MESSAGE_WHISPER") ) {
      throw new Error(game.i18n.localize("ERROR.CantWhisper"));
    }

    // Update chat data
    chatData.whisper = users.map(u => u.id);
    chatData.content = message;
    chatData.sound = CONFIG.sounds.notification;
  }

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

  /**
   * Process messages which are posted using a chat whisper command
   * @param {string} command          The chat command type
   * @param {RegExpMatchArray} match  The matched RegExp expressions
   * @param {Object} chatData         The initial chat data
   * @param {Object} createOptions    Options used to create the message
   * @private
   */
  _processChatCommand(command, match, chatData, createOptions) {
    if ( ["ic", "emote"].includes(command) && !(chatData.speaker.actor || chatData.speaker.token) ) {
      throw new Error("You cannot chat in-character without an identified speaker");
    }
    chatData.content = match[2].replace(/\n/g, "<br>");

    // Augment chat data
    if ( command === "ic" ) {
      chatData.style = CONST.CHAT_MESSAGE_STYLES.IC;
      createOptions.chatBubble = true;
    } else if ( command === "emote" ) {
      chatData.style = CONST.CHAT_MESSAGE_STYLES.EMOTE;
      chatData.content = `${chatData.speaker.alias} ${chatData.content}`;
      createOptions.chatBubble = true;
    }
    else {
      chatData.style = CONST.CHAT_MESSAGE_STYLES.OOC;
      delete chatData.speaker;
    }
  }

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

  /**
   * Process messages which execute a macro.
   * @param {string} command  The chat command typed.
   * @param {RegExpMatchArray} match  The RegExp matches.
   * @private
   */
  _processMacroCommand(command, match) {

    // Parse the macro command with the form /macro {macroName} [param1=val1] [param2=val2] ...
    let [macroName, ...params] = match[2].split(" ");
    let expandName = true;
    const scope = {};
    let k = undefined;
    for ( const p of params ) {
      const kv = p.split("=");
      if ( kv.length === 2 ) {
        k = kv[0];
        scope[k] = kv[1];
        expandName = false;
      }
      else if ( expandName ) macroName += ` ${p}`; // Macro names may contain spaces
      else if ( k ) scope[k] += ` ${p}`;  // Expand prior argument value
    }
    macroName = macroName.trimEnd(); // Eliminate trailing spaces

    // Get the target macro by number or by name
    let macro;
    if ( Number.isNumeric(macroName) ) {
      const macroID = game.user.hotbar[macroName];
      macro = game.macros.get(macroID);
    }
    if ( !macro ) macro = game.macros.getName(macroName);
    if ( !macro ) throw new Error(`Requested Macro "${macroName}" was not found as a named macro or hotbar position`);

    // Execute the Macro with provided scope
    return macro.execute(scope);
  }

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

  /**
   * Add a sent message to an array of remembered messages to be re-sent if the user pages up with the up arrow key
   * @param {string} message    The message text being remembered
   * @private
   */
  _remember(message) {
    if ( this._sentMessages.length === 5 ) this._sentMessages.splice(4, 1);
    this._sentMessages.unshift(message);
    this._sentMessageIndex = -1;
  }

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

  /**
   * Recall a previously sent message by incrementing up (1) or down (-1) through the sent messages array
   * @param {number} direction    The direction to recall, positive for older, negative for more recent
   * @return {string}             The recalled message, or an empty string
   * @private
   */
  _recall(direction) {
    if ( this._sentMessages.length > 0 ) {
      let idx = this._sentMessageIndex + direction;
      this._sentMessageIndex = Math.clamp(idx, -1, this._sentMessages.length-1);
    }
    return this._sentMessages[this._sentMessageIndex] || "";
  }

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

  /** @inheritdoc */
  _contextMenu(html) {
    ContextMenu.create(this, html, ".message", this._getEntryContextOptions());
  }

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

  /**
   * Get the ChatLog entry context options
   * @return {object[]}   The ChatLog entry context options
   * @private
   */
  _getEntryContextOptions() {
    return [
      {
        name: "CHAT.PopoutMessage",
        icon: '<i class="fas fa-external-link-alt fa-rotate-180"></i>',
        condition: li => {
          const message = game.messages.get(li.data("messageId"));
          return message.getFlag("core", "canPopout") === true;
        },
        callback: li => {
          const message = game.messages.get(li.data("messageId"));
          new ChatPopout(message).render(true);
        }
      },
      {
        name: "CHAT.RevealMessage",
        icon: '<i class="fas fa-eye"></i>',
        condition: li => {
          const message = game.messages.get(li.data("messageId"));
          const isLimited = message.whisper.length || message.blind;
          return isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
        },
        callback: li => {
          const message = game.messages.get(li.data("messageId"));
          return message.update({whisper: [], blind: false});
        }
      },
      {
        name: "CHAT.ConcealMessage",
        icon: '<i class="fas fa-eye-slash"></i>',
        condition: li => {
          const message = game.messages.get(li.data("messageId"));
          const isLimited = message.whisper.length || message.blind;
          return !isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
        },
        callback: li => {
          const message = game.messages.get(li.data("messageId"));
          return message.update({whisper: ChatMessage.getWhisperRecipients("gm").map(u => u.id), blind: false});
        }
      },
      {
        name: "SIDEBAR.Delete",
        icon: '<i class="fas fa-trash"></i>',
        condition: li => {
          const message = game.messages.get(li.data("messageId"));
          return message.canUserModify(game.user, "delete");
        },
        callback: li => {
          const message = game.messages.get(li.data("messageId"));
          return message.delete();
        }
      }
    ];
  }

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

  /**
   * Handle keydown events in the chat entry textarea
   * @param {KeyboardEvent} event
   * @private
   */
  _onChatKeyDown(event) {
    const code = event.code;
    const textarea = event.currentTarget;

    if ( event.originalEvent.isComposing ) return; // Ignore IME composition

    // UP/DOWN ARROW -> Recall Previous Messages
    const isArrow = ["ArrowUp", "ArrowDown"].includes(code);
    if ( isArrow ) {
      if ( this._pendingText ) return;
      event.preventDefault();
      textarea.value = this._recall(code === "ArrowUp" ? 1 : -1);
      return;
    }

    // ENTER -> Send Message
    const isEnter = ( (code === "Enter") || (code === "NumpadEnter") ) && !event.shiftKey;
    if ( isEnter ) {
      event.preventDefault();
      const message = textarea.value;
      if ( !message ) return;
      event.stopPropagation();
      this._pendingText = "";

      // Prepare chat message data and handle result
      return this.processMessage(message).then(() => {
        textarea.value = "";
        this._remember(message);
      }).catch(error => {
        ui.notifications.error(error);
        throw error;
      });
    }

    // BACKSPACE -> Remove pending text
    if ( event.key === "Backspace" ) {
      this._pendingText = this._pendingText.slice(0, -1);
      return
    }

    // Otherwise, record that there is pending text
    this._pendingText = textarea.value + (event.key.length === 1 ? event.key : "");
  }

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

  /**
   * Handle setting the preferred roll mode
   * @param {Event} event
   * @private
   */
  _onChangeRollMode(event) {
    event.preventDefault();
    game.settings.set("core", "rollMode", event.target.value);
  }

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

  /**
   * Handle single message deletion workflow
   * @param {Event} event
   * @private
   */
  _onDeleteMessage(event) {
    event.preventDefault();
    const li = event.currentTarget.closest(".message");
    const messageId = li.dataset.messageId;
    const message = game.messages.get(messageId);
    return message ? message.delete() : this.deleteMessage(messageId);
  }

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

  /**
   * Handle clicking of dice tooltip buttons
   * @param {Event} event
   * @private
   */
  _onDiceRollClick(event) {
    event.preventDefault();

    // Toggle the message flag
    let roll = event.currentTarget;
    const message = game.messages.get(roll.closest(".message").dataset.messageId);
    message._rollExpanded = !message._rollExpanded;

    // Expand or collapse tooltips
    const tooltips = roll.querySelectorAll(".dice-tooltip");
    for ( let tip of tooltips ) {
      if ( message._rollExpanded ) $(tip).slideDown(200);
      else $(tip).slideUp(200);
      tip.classList.toggle("expanded", message._rollExpanded);
    }
  }

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

  /**
   * Handle click events to export the chat log
   * @param {Event} event
   * @private
   */
  _onExportLog(event) {
    event.preventDefault();
    game.messages.export();
  }

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

  /**
   * Handle click events to flush the chat log
   * @param {Event} event
   * @private
   */
  _onFlushLog(event) {
    event.preventDefault();
    game.messages.flush(this.#jumpToBottomElement);
  }

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

  /**
   * Handle scroll events within the chat log container
   * @param {UIEvent} [event] A triggering scroll event
   * @private
   */
  _onScrollLog(event) {
    if ( !this.rendered ) return;
    const log = event?.target ?? this.element[0].querySelector("#chat-log");
    const pct = log.scrollTop / (log.scrollHeight - log.clientHeight);
    if ( !this.#jumpToBottomElement ) this.#jumpToBottomElement = this.element.find(".jump-to-bottom")[0];
    this.#isAtBottom = (pct > 0.99) || Number.isNaN(pct);
    this.#jumpToBottomElement.classList.toggle("hidden", this.#isAtBottom);
    if ( pct < 0.01 ) return this._renderBatch(this.element, CONFIG.ChatMessage.batchSize);
  }

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

  /**
   * Update roll mode select dropdowns when the setting is changed
   * @param {string} mode     The new roll mode setting
   */
  static _setRollMode(mode) {
    for ( let select of $(".roll-type-select") ) {
      for ( let option of select.options ) {
        option.selected = option.value === mode;
      }
    }
  }
}
