import HandlebarsApplicationMixin from "../../api/handlebars-application.mjs";
import AbstractSidebarTab from "../sidebar-tab.mjs";
import ChatMessage from "@client/documents/chat-message.mjs";
import Roll from "@client/dice/roll.mjs";
import Hooks from "@client/helpers/hooks.mjs";
import HTMLSecretBlockElement from "@client/applications/elements/secret-block.mjs";
import {renderTemplate} from "@client/applications/handlebars.mjs";

/**
 * @import {
 *   ApplicationClosingOptions, ApplicationRenderContext, ApplicationRenderOptions, ApplicationClickAction
 * } from "../../_types.mjs"
 */

/**
 * The sidebar chat tab.
 * @extends {AbstractSidebarTab}
 * @mixes HandlebarsApplication
 */
export default class ChatLog extends HandlebarsApplicationMixin(AbstractSidebarTab) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["flexcol"],
    window: {
      title: "CHAT.Title"
    },
    actions: {
      deleteMessage: this.#onDeleteMessage,
      dismissMessage: this.#onDismissNotification,
      expandRoll: this.#onExpandRoll,
      export: this.#onExportLog,
      flush: this.#onFlushLog,
      jumpToBottom: this.#onJumpToBottom,
      messageMode: this.#onChangeMessageMode
    }
  };

  /** @override */
  static tabName = "chat";

  /** @override */
  static PARTS = {
    log: {
      template: "templates/sidebar/tabs/chat/log.hbs",
      templates: ["templates/sidebar/tabs/chat/notifications.hbs"]
    },
    input: {
      template: "templates/sidebar/tabs/chat/input.hbs"
    }
  };

  /**
   * @callback ChatCommandCallback
   * Called in the context of a {@link ChatLog} instance.
   * @param {string} command                                      The matched command name.
   * @param {RegExpMatchArray|RegExpMatchArray[]|string[]} match  The regex match result.
   * @param {object} chatData                                     Chat message data.
   * @param {object} createOptions                                Options passed to ChatMessage.create.
   * @returns {Promise<false|void>}                               Return false to prevent message creation.
   */

  /**
   * @typedef ChatCommandPattern
   * @property {RegExp} rgx               The regular expression pattern used to match this command.
   * @property {ChatCommandCallback} fn   The processing function invoked when this command is matched.
   * @property {keyof CONFIG.ChatMessage.modes} [mode]  A chat message mode to enforce for this command. Otherwise,
   *                                      the default message mode is applied.
   * @property {boolean} [isRoll=false]   Is this command related to rolling dice?
   * @property {boolean} [isMultiline=false] Can this command be processed over multiple lines?
   */

  /**
   * A registry of chat commands with associated regular expression patterns and processing functions.
   * @type {Object<string, ChatCommandPattern>}
   */
  static CHAT_COMMANDS = (() => {
    const dice = "([^#]+)(?:#(.*))?";       // Dice expression with appended flavor text
    const any = "([^]*)";                   // Any character, including new lines
    const diceCommand = async function(command, match, chatData, createOptions) {
      await this.#processDiceCommand(command, match, chatData, createOptions);
    };
    const chatCommand = function(command, match, chatData, createOptions) {
      this.#processChatCommand(command, match, chatData, createOptions);
    };
    const whisperCommand = function(command, match, chatData) {
      this.#processWhisperCommand(command, match, chatData);
    };
    const macroCommand = function(command, match) {
      this.#processMacroCommand(match);
      return false; // Prevent message creation
    };
    const diceCmd = {fn: diceCommand, isRoll: true, isMultiline: true};
    return {
      roll: {rgx: new RegExp(`^(\\/r(?:oll)? )${dice}$`, "i"), ...diceCmd},
      gmroll: {rgx: new RegExp(`^(\\/gmr(?:oll)? )${dice}$`, "i"), mode: "gm", ...diceCmd},
      blindroll: {rgx: new RegExp(`^(\\/b(?:lind)?r(?:oll)? )${dice}$`, "i"), mode: "blind", ...diceCmd},
      selfroll: {rgx: new RegExp(`^(\\/s(?:elf)?r(?:oll)? )${dice}$`, "i"), mode: "self", ...diceCmd},
      publicroll: {rgx: new RegExp(`^(\\/p(?:ublic)?r(?:oll)? )${dice}$`, "i"), mode: "public", ...diceCmd},
      ooc: {rgx: new RegExp(`^(/ooc )${any}`, "i"), fn: chatCommand},
      ic: {rgx: new RegExp(`^(/ic )${any}`, "i"), mode: "ic", fn: chatCommand},
      emote: {rgx: new RegExp(`^(/(?:em(?:ote)?|me) )${any}`, "i"), mode: "ic", fn: chatCommand},
      gm: {rgx: new RegExp(`^(/gm )${any}`, "i"), fn: whisperCommand},
      whisper: {rgx: new RegExp(/^(\/w(?:hisper)?\s)(\[[^\]]+]|\S+)\s*([^]*)/, "i"), fn: whisperCommand},
      reply: {rgx: new RegExp(`^(/reply )${any}`, "i"), fn: whisperCommand},
      players: {rgx: new RegExp(`^(/players )${any}`, "i"), fn: whisperCommand},
      macro: {rgx: new RegExp(`^(\\/m(?:acro)? )${any}`, "i"), fn: macroCommand}
    };
  })();

  /**
   * Patterns registered by legacy code via writes to {@link ChatLog.MESSAGE_PATTERNS}.
   * @type {Record<string, RegExp>}
   */
  static #legacyPatterns = {};

  /**
   * The maximum number of messages to retain in the history in a given session.
   * @type {number}
   */
  static MAX_MESSAGE_HISTORY = 16;

  /**
   * The number of milliseconds to keep a chat card notification until it is automatically dismissed.
   * @type {number}
   */
  static NOTIFY_DURATION = 5000;

  /**
   * The notification ticker frequency.
   * @type {number}
   */
  static NOTIFY_TICKER = 500;

  /**
   * The number of milliseconds to wait before unpausing the notification queue.
   * @type {number}
   */
  static NOTIFY_UNPAUSE = 2000;

  /**
   * The number of milliseconds to display the chat notification pip.
   * @type {number}
   */
  static PIP_DURATION = 3000;

  /**
   * A template element used to convert messages to plain text.
   * @type {HTMLTemplateElement}
   */
  static #PLAIN_TEXT;

  /**
   * How often, in milliseconds, to update timestamps.
   * @type {number}
   */
  static UPDATE_TIMESTAMP_FREQUENCY = 1000 * 15;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

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

  /**
   * Message history management.
   * @type {{ queue: string[], index: number, pending: string }}
   */
  get history() {
    return this.#history;
  }

  #history = {
    index: -1,
    pending: "",
    queue: []
  };

  /**
   * The chat input element.
   * @type {HTMLProseMirrorElement}
   */
  #inputElement;

  /**
   * A flag for whether the chat log is currently scrolled to the bottom.
   * @type {boolean}
   */
  get isAtBottom() {
    return this.#isAtBottom;
  }

  #isAtBottom = true;

  /**
   * The jump to bottom button.
   * @type {HTMLButtonElement}
   */
  #jumpToBottomElement;

  /**
   * Track the ID of the oldest message displayed in the log.
   * @type {string|null}
   */
  #lastId;

  /**
   * Store the last whisper this user received for replying.
   * @type {ChatMessage}
   */
  #lastWhisper;

  /**
   * The chat notifications container.
   * @type {HTMLDivElement}
   */
  #notificationsElement;

  /**
   * The debounced function for returning the chat notification queue to an inactive state.
   * @type {function(): void}
   */
  #notifyAlertDebounce =
    foundry.utils.debounce(this.#onNotifyInactive.bind(this), this.constructor.NOTIFY_DURATION);

  /**
   * The debounced function for unpausing the notification queue.
   * @type {function(): void}
   */
  #notifyUnpauseDebounce =
    foundry.utils.debounce(this.#onUnpauseNotifications.bind(this), this.constructor.NOTIFY_UNPAUSE);

  /**
   * Debounced function for setting the overflowing state of the chat scrollback.
   * @type {function(): void}
   */
  #overflowingDebounce = foundry.utils.debounce(this.#setOverflowing.bind(this), 100);

  /**
   * The debounced function for hiding the notification pip.
   * @type {function(): void}
   */
  #pipHideDebounce = foundry.utils.debounce(this.#onHidePip.bind(this), this.constructor.PIP_DURATION);

  /**
   * Chat controls containing message-modes buttons and log actions
   * @type {HTMLDivElement}
   */
  #chatControls;

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

  /**
   * Whether batch rendering is currently in-progress.
   * @type {boolean}
   */
  #renderingBatch = false;

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

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    // If the log has already been rendered once, prevent it from being re-rendered.
    if ( this.rendered ) options.parts = options.parts.filter(p => (p !== "log") && (p !== "input"));
  }

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

  /**
   * Render a batch of additional messages, prepending them to the top of the log.
   * @param {number} size  The batch size.
   * @returns {Promise<void>}
   */
  async #doRenderBatch(size) {
    if ( !this.rendered ) {
      this.#renderingBatch = false;
      return;
    }

    const messages = game.messages.contents;
    const log = this.element.querySelector(".chat-log");

    // Get the index of the last rendered chat message
    let lastIdx = messages.findIndex(m => m.id === this.#lastId);
    lastIdx = lastIdx > -1 ? lastIdx : messages.length;
    if ( !lastIdx ) {
      this.#renderingBatch = false;
      return;
    }

    // Get the next batch to render
    const targetIdx = Math.max(lastIdx - size, 0);
    const elements = [];
    for ( let i = targetIdx; i < lastIdx; i++ ) {
      const message = messages[i];
      if ( !message.visible ) continue;
      message.logged = true;
      try {
        elements.push(await this.constructor.renderMessage(message));
      } catch(err) {
        Hooks.onError("ChatLog##doRenderBatch", err, {
          msg: `Chat message ${message.id} failed to render`,
          log: "error"
        });
      }
    }

    // Prepend the HTML
    log.prepend(...elements);
    this.#lastId = messages[targetIdx].id;
    this.#renderingBatch = false;
    if ( !this.isPopout ) this.#overflowingDebounce();
  }

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

  /**
   * Get context menu entries for chat messages in the log.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getEntryContextOptions() {
    return [{
      label: "CHAT.PopoutMessage",
      icon: "fa-solid fa-up-right-from-square fa-rotate-180",
      visible: li => {
        const message = game.messages.get(li.dataset.messageId);
        return message.getFlag("core", "canPopout") === true;
      },
      onClick: (event, li) => {
        const message = game.messages.get(li.dataset.messageId);
        new CONFIG.ChatMessage.popoutClass({message}).render({force: true});
      }
    }, {
      label: "CHAT.RevealMessage",
      icon: "fa-solid fa-eye",
      visible: li => {
        const message = game.messages.get(li.dataset.messageId);
        const isLimited = message.whisper.length || message.blind;
        return isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
      },
      onClick: (event, li) => {
        const message = game.messages.get(li.dataset.messageId);
        return message.update({whisper: [], blind: false});
      }
    }, {
      label: "CHAT.ConcealMessage",
      icon: "fa-solid fa-eye-slash",
      visible: li => {
        const message = game.messages.get(li.dataset.messageId);
        const isLimited = message.whisper.length || message.blind;
        return !isLimited && (game.user.isGM || message.isAuthor) && message.isContentVisible;
      },
      onClick: (event, li) => {
        const message = game.messages.get(li.dataset.messageId);
        return message.update({whisper: ChatMessage.getWhisperRecipients("gm").map(u => u.id), blind: false});
      }
    }, {
      label: "SIDEBAR.Delete",
      icon: "fa-solid fa-trash",
      visible: li => {
        const message = game.messages.get(li.dataset.messageId);
        return message.canUserModify(game.user, "delete");
      },
      onClick: (event, li) => {
        const message = game.messages.get(li.dataset.messageId);
        return message ? message.delete() : this.deleteMessage(li.dataset.messageId);
      }
    }];
  }

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

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    if ( this.isPopout ) ui.chat._toggleNotifications(options);
    else {
      const { chatBackground } = game.settings.get("core", "uiConfig");
      this.element.classList.toggle("with-background", chatBackground);
      setInterval(this.updateTimestamps.bind(this), this.constructor.UPDATE_TIMESTAMP_FREQUENCY);
      await this.#renderNotifications();
    }
    await this.renderBatch(CONFIG.ChatMessage.batchSize);
    /** @fires {hookEvents:getChatMessageContextOptions} */
    this._createContextMenu(this._getEntryContextOptions, ".message[data-message-id]", {
      hookName: "getChatMessageContextOptions",
      parentClassHooks: false,
      fixed: true
    });
  }

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

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this._toggleNotifications(options);
  }

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

  /** @inheritDoc */
  async _postRender(context, options) {
    await super._postRender(context, options);
    if ( options.isFirstRender ) await this.scrollBottom({waitImages: true});
  }

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

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "input": await this._prepareInputContext(context, options); break;
    }
    return context;
  }

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

  /**
   * Prepare rendering context for the chat panel's message input component.
   * @param {ApplicationRenderContext} context
   * @param {ApplicationRenderOptions} options
   * @protected
   */
  async _prepareInputContext(context, options) {
    context.isAtBottom = this.isAtBottom;
  }

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

  /** @inheritDoc */
  async _renderHTML(context, options) {
    const parts = await super._renderHTML(context, options);
    if ( parts.input ) this.#jumpToBottomElement = parts.input.querySelector(".jump-to-bottom");
    return parts;
  }


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

  /**
   * Render chat notifications framework.
   * @returns {Promise<void>}
   */
  async #renderNotifications() {
    const right = document.getElementById("ui-right-column-1") ?? document.body;
    const mode = game.settings.get("core", "messageMode");
    const messageModes = Object.entries(CONFIG.ChatMessage.modes).map(([action, {label, icon}]) => {
      return {icon, label, action, active: action === mode};
    });

    const html = await renderTemplate("templates/sidebar/tabs/chat/notifications.hbs", {user: game.user, messageModes});
    [this.#notificationsElement, this.#inputElement, this.#chatControls] = foundry.utils.parseHTML(html);
    this.#notificationsElement.addEventListener("click", this._onClickNotification.bind(this));
    this.#inputElement.addEventListener("plugins", this._onConfigurePlugins.bind(this));

    right.append(this.#notificationsElement);
    this.#notificationsElement.append(this.#inputElement, this.#chatControls);
    window.addEventListener("blur", this.#onBlurWindow.bind(this));
    setInterval(this.#checkNotificationLifeSpan.bind(this), this.constructor.NOTIFY_TICKER);
  }

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

  /** @inheritDoc */
  _preSyncPartState(partId, newElement, priorElement, state) {
    super._preSyncPartState(partId, newElement, priorElement, state);
    switch ( partId ) {
      case "input": this._preSyncInputState(newElement, priorElement, state); break;
    }
  }

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

  /**
   * Prepare data used to synchronize the state of the chat input.
   * @param {HTMLElement} newElement    The newly-rendered element.
   * @param {HTMLElement} priorElement  The existing element.
   * @param {object} state              A state object which is used to synchronize after replacement.
   * @protected
   */
  _preSyncInputState(newElement, priorElement, state) {
    const textarea = priorElement.querySelector(".chat-input");
    state.message = textarea?.value;
  }

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

  /** @inheritDoc */
  _syncPartState(partId, newElement, priorElement, state) {
    super._syncPartState(partId, newElement, priorElement, state);
    switch ( partId ) {
      case "input": this._syncInputState(newElement, priorElement, state); break;
    }
  }

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

  /**
   * Synchronize the state of the chat input.
   * @param {HTMLElement} newElement    The newly-rendered element.
   * @param {HTMLElement} priorElement  The element being replaced.
   * @param {object} state              The state object used to synchronize the pre- and post-render states.
   * @protected
   */
  _syncInputState(newElement, priorElement, state) {
    const textarea = newElement.querySelector(".chat-input");
    if ( textarea ) textarea.value = state.message;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachPartListeners(partId, element, options) {
    super._attachPartListeners(partId, element, options);
    switch ( partId ) {
      case "log": this._attachLogListeners(element, options); break;
    }
  }

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

  /**
   * Attach listeners to the chat log.
   * @param {HTMLElement} element  The log element.
   * @param {ApplicationRenderOptions} options
   * @protected
   */
  _attachLogListeners(element, options) {
    element.addEventListener("scroll", this.#onScrollLog.bind(this), {passive: true});
    element.addEventListener("change", this.#onChangeLog.bind(this));
  }

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

  /** @inheritDoc */
  _onActivate() {
    super._onActivate();
    this._toggleNotifications();
  }

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

  /**
   * Handle unpausing the notification queue when the window loses focus.
   */
  #onBlurWindow() {
    this.#notificationsElement.querySelectorAll(".message.hovered").forEach(el => el.classList.remove("hovered"));
    this.#notifyUnpauseDebounce();
  }

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

  /**
   * Handle changing the message mode.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onChangeMessageMode(event) {
    const mode = event.target.dataset.mode;
    game.settings.set("core", "messageMode", mode);
  }

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

  /**
   * Handle clicking a chat card notification.
   * Treat action button clicks within the Notifications UI as action clicks on the ChatLog instance itself.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onClickNotification(event) {
    const target = event.target.closest("[data-action]");
    if ( !target ) return;
    const {action} = target.dataset;
    let handler = this.options.actions[action];
    let buttons = [0];
    if ( typeof handler === "object" ) {
      buttons = handler.buttons;
      handler = handler.handler;
    }
    if ( buttons.includes(event.button) ) handler?.call(this, event, target);
    else this._onClickAction(event, target);
  }

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

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    this.#lastId = null;
  }

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

  /**
   * Configure chat-specific plugins.
   * @param {ProseMirrorPluginsEvent} event  The plugin configuration event.
   * @protected
   */
  _onConfigurePlugins(event) {
    const {defaultSchema, plugins} = foundry.prosemirror;
    if ( event.target.closest("#chat-message") ) {
      Object.assign(event.plugins, {
        chatInput: plugins.chat.ChatInputPlugin.build(defaultSchema, {chat: this}),
        keyMaps: plugins.ProseMirrorKeyMaps.build(defaultSchema, {joinDoubleBreak: true}),
        menu: plugins.chat.ChatMenuPlugin.build(defaultSchema)
      });
    }
  }

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

  /** @inheritDoc */
  _onDeactivate() {
    super._onDeactivate();
    this._toggleNotifications();
  }

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

  /**
   * Handle deleting a single message from the log.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static async #onDeleteMessage(event) {
    event.preventDefault();
    const {messageId} = event.target.closest("[data-message-id]")?.dataset ?? {};
    const message = game.messages.get(messageId);
    if ( message ) await message.delete();
    else await this.deleteMessage(messageId);
  }

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

  /**
   * Handle dismissing a chat card notification.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static async #onDismissNotification(_event, target) {
    const message = target.closest(".chat-message");
    await this.#dismissNotification(message);
  }

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

  /**
   * Handle toggling the expanded state of a roll breakdown.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onExpandRoll(event, target) {
    event.preventDefault();
    target.classList.toggle("expanded");
  }

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

  /**
   * Handle exporting the chat log.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onExportLog() {
    game.messages.export();
  }

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

  /**
   * Handle flushing the chat log.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onFlushLog() {
    game.messages.flush();
  }

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

  /**
   * Handle hiding the chat tab notification pip.
   */
  #onHidePip() {
    document.querySelector('#sidebar .tabs [data-tab="chat"] + .notification-pip')?.classList.remove("active");
  }

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

  /**
   * Handle pausing the chat notification log when interacting with a chat notification card.
   * @param {PointerEvent} event  The triggering event.
   */
  #onHoverNotification(event) {
    event.currentTarget.classList.toggle("hovered", event.type === "pointerenter");
    if ( event.type === "pointerleave" ) this.#notifyUnpauseDebounce();
  }

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

  /**
   * Handle jumping to the bottom of the chat log.
   * @this {ChatLog}
   * @type {ApplicationClickAction}
   */
  static #onJumpToBottom() {
    this.scrollBottom();
  }

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

  /**
   * Return the chat notification queue to the inactive state.
   */
  #onNotifyInactive() {
    this.#notificationsElement.classList.remove("active");
  }

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

  /**
   * Handle scroll events within the chat log.
   * @param {UIEvent} [event]  A triggering scroll event.
   */
  #onScrollLog(event) {
    if ( !this.rendered ) return;
    const log = event?.currentTarget ?? this.element.querySelector(".chat-scroll");
    const pct = log.scrollTop / (log.scrollHeight - log.clientHeight);
    this.#isAtBottom = (pct > 0.99) || Number.isNaN(pct);
    this.#jumpToBottomElement.toggleAttribute("hidden", this.#isAtBottom);
    log.classList.toggle("scrolled", !this.#isAtBottom);
    const top = log.querySelector("li.message");
    if ( pct < 0.01 ) return this.renderBatch(CONFIG.ChatMessage.batchSize).then(() => {
      // Retain the scroll position at the top-most element before the extra messages were prepended to the log.
      if ( top ) log.scrollTop = top.offsetTop;
    });
  }

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

  /**
   * Handle changes to an input element within the log.
   * @param {Event} event
   */
  #onChangeLog(event) {
    if ( !foundry.utils.isElementInstanceOf(event.target, HTMLSecretBlockElement) ) return;
    const messageId = event.target.closest("[data-message-id]")?.dataset.messageId;
    if ( !messageId ) return;
    const message = game.messages.get(messageId);
    if ( !message ) return;
    const modified = event.target.toggleRevealed(message.content);
    message.update({content: modified});
  }

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

  /**
   * Handle unpausing the notification queue and scrolling back down to the bottom.
   */
  #onUnpauseNotifications() {
    if ( this.#notificationsElement.querySelector(".message.hovered") ) return;

    // Now the queue is unpaused, clear any messages that have already expired.
    this.#checkNotificationLifeSpan();

    // Scroll to the bottom.
    this.#notificationsElement.querySelector(":scope > .overflow").scrollTop = 0;
  }

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

  /** @inheritDoc */
  async _preClose(options) {
    if ( this.isPopout ) this._toggleNotifications({...options, closing: true});
  }

  /* -------------------------------------------- */
  /*  Message Input                               */
  /* -------------------------------------------- */

  /**
   * Parse a chat string to identify the chat command (if any) which was used.
   * @param {string} message  The message to parse.
   * @returns {[string, string[]|RegExpMatchArray|RegExpMatchArray[], ChatCommandCallback]}  The identified command,
   *                          regex match, and associated handler function.
   */
  static parse(message) {
    const template = ChatLog.#PLAIN_TEXT ??= document.createElement("template");
    template.innerHTML = message;
    template.content.querySelectorAll("br").forEach(br => br.replaceWith("\n"));
    const text = template.content.textContent;
    const html = message.replace(/^<p>|<\/p>$/gi, "");

    // Check registered CHAT_COMMANDS first
    for ( const [rule, {rgx, fn, isMultiline, isRoll}] of Object.entries(this.CHAT_COMMANDS) ) {

      // For multi-line matches, the first line must match
      if ( isMultiline ) {
        const lines = (isRoll ? text : html).split(/<br\b[^>]*>|\n/i);
        if ( rgx.test(lines[0]) ) return [rule, lines.map(l => l.match(rgx)), fn];
      }

      // For single-line matches, match directly
      else {
        const match = (isRoll ? text : html).match(rgx);
        if ( match ) return [rule, match, fn];
      }
    }

    /** @deprecated since v14 */
    for ( const [rule, rgx] of Object.entries(ChatLog.#legacyPatterns) ) {
      if ( this.MULTILINE_COMMANDS.has(rule) ) {
        foundry.utils.logCompatibilityWarning("Assigning a CHAT_COMMANDS entry to ChatLog.MULTILINE_COMMANDS is"
          + " deprecated in favor of directly configuring `isMultiline: true` in the CHAT_COMMANDS record.",
        {since: 14, until: 16, once: true});
        const lines = html.split(/<br\b[^>]*>|\n/i);
        if ( rgx.test(lines[0]) ) return [rule, lines.map(l => l.match(rgx))];
      } else {
        const match = html.match(rgx);
        if ( match ) return [rule, match];
      }
    }

    // Any remaining unrecognized slash command is invalid.
    const invalidMatch = html.match(/^(\/\S+)/);
    if ( invalidMatch ) return ["invalid", invalidMatch];

    return ["none", [message, "", message]];
  }

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

  /**
   * 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<ChatMessage|void>}         The created ChatMessage Document, or void if we were executing a
   *                                              macro instead.
   * @throws {Error}                              If an invalid command is found.
   */
  async processMessage(message, {speaker}={}) {
    message = message.trim();
    if ( !message ) return;
    const cls = ChatMessage.implementation;
    speaker ??= cls.getSpeaker();
    const createOptions = {};

    // Set up basic chat data
    const chatData = {speaker, user: game.user.id};
    if ( Hooks.call("chatMessage", this, message, chatData) === false ) return;

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

    // Special handlers for no command
    if ( command === "invalid" ) throw new Error(_loc("CHAT.InvalidCommand", {command: match[1]}));
    if ( command === "none" ) {
      const mode = game.settings.get("core", "messageMode");
      createOptions.messageMode = mode;
      command = mode === "ic" ? "ic" : "ooc";
      fn = this.constructor.CHAT_COMMANDS[command].fn;
    }

    // Process message data based on the identified command type
    const result = await fn?.call(this, command, match, chatData, createOptions);
    if ( result === false ) return;

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

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

  /**
   * Process non-whispered messages.
   * @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
   * @throws {Error}                  If attempting to emote or chat in-character without a speaker.
   */
  #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" ) createOptions.messageMode = "ic";
    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 dice roll commands.
   * @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
   */
  async #processDiceCommand(command, matches, chatData, createOptions) {
    const actor = ChatMessage.getSpeakerActor(chatData.speaker) || game.user.character;
    const rollData = actor ? actor.getRollData() : {};
    const rolls = [];
    const messageMode = this.constructor.CHAT_COMMANDS[command]?.mode || game.settings.get("core", "messageMode");
    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: messageMode !== "blind"});
      rolls.push(roll);
    }
    chatData.rolls = rolls;
    chatData.sound = CONFIG.sounds.dice;
    chatData.content = rolls.reduce((t, r) => t + r.total, 0);
    createOptions.messageMode = messageMode;
  }

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

  /**
   * Process messages which execute a macro.
   * @param {RegExpMatchArray} match  The RegExp matches.
   * @throws {Error}                  If the macro could not be found.
   */
  #processMacroCommand(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
    macro.execute(scope);
  }

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

  /**
   * Process whispered messages.
   * @param {string} command          The chat command type
   * @param {RegExpMatchArray} match  The matched RegExp expressions
   * @param {Object} chatData         The initial chat data
   * @throws {Error}                  If this user does not have permission to whisper, or there are no valid whisper
   *                                  recipients.
   */
  #processWhisperCommand(command, match, chatData) {
    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)).filter(_ => _);
        }
        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(_loc("ERROR.NoTargetUsersForWhisper"));
    if ( users.some(u => !u.isGM) && !game.user.can("MESSAGE_WHISPER") ) {
      throw new Error(_loc("ERROR.CantWhisper"));
    }

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

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Delete a single message from the chat log.
   * @param {string} messageId                   The ID of the ChatMessage Document to remove from the log.
   * @param {object} [options]
   * @param {boolean} [options.deleteAll=false]  Delete all messages from the log.
   * @returns {Promise<void>}
   */
  deleteMessage(messageId, options={}) {
    return this.#renderingQueue.add(this.#deleteMessage.bind(this), messageId, options);
  }

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

  /**
   * Trigger a notification that alerts the user visually and audibly of new chat activity.
   * @param {ChatMessage} message             The created or updated message.
   * @param {object} [options]
   * @param {HTMLElement} [options.existing]  The existing rendered chat card, if it exists.
   * @param {boolean} [options.newMessage]    Whether this is a new message.
   */
  notify(message, {existing, newMessage}={}) {
    if ( !this.rendered ) return;

    // Post a chat card notification if the setting is enabled and this is a new message.
    if ( newMessage && this._shouldShowNotifications() ) this.#postNotification(message, {existing});

    // Otherwise show a notification pip on the chat tab.
    else {
      document.querySelector('#sidebar .tabs [data-tab="chat"] + .notification-pip')?.classList.add("active");
      this.#pipHideDebounce();
    }

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

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

  /**
   * Post a single chat message to the log.
   * @param {ChatMessage} message             The chat message.
   * @param {object} [options]
   * @param {string} [options.before]         An existing message ID to prepend the posted message to, by default the
   *                                          new message is appended to the end of the log.
   * @param {boolean} [options.notify=false]  Trigger a notification which shows the log as having a new unread message.
   * @param {boolean} [options.scroll]        Pass true to always scroll-to-bottom, or false to suppress this. Omitting
   *                                          this option preserves the default conditional scroll-to-bottom.
   * @returns {Promise<void>}                 A Promise which resolves once the message has been posted.
   */
  async postOne(message, options={}) {
    if ( !message.visible ) return;
    return this.#renderingQueue.add(this.#postOne.bind(this), message, options);
  }

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

  /**
   * Render a batch of additional messages, prepending them to the top of the log.
   * @param {number} size  The batch size.
   * @returns {Promise<void>}
   */
  async renderBatch(size) {
    if ( this.#renderingBatch ) return;
    this.#renderingBatch = true;
    return this.#renderingQueue.add(this.#doRenderBatch.bind(this), size);
  }

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

  /**
   * Re-render a message in the chat log, keeping its ephemeral state synchronized.
   * @param {ChatMessage} message   The ChatMessage Document.
   * @param {HTMLElement} existing  The existing rendered element.
   * @param {object} [options]      Options forwarded to {@link foundry.documents.ChatMessage#renderHTML}.
   */
  async #rerenderMessage(message, existing, options={}) {
    const expanded = Array.from(existing.querySelectorAll('[data-action="expandRoll"]')).map(el => {
      return el.classList.contains("expanded");
    });
    const replacement = await this.constructor.renderMessage(message, options);
    const rolls = replacement.querySelectorAll('[data-action="expandRoll"]');
    for ( let i = 0; i < rolls.length; i++ ) rolls[i].classList.toggle("expanded", expanded[i] ?? false);
    replacement.hidden = existing.hidden;
    replacement.style.opacity = existing.style.opacity;
    existing.replaceWith(replacement);
    if ( "_lifeSpan" in existing ) {
      replacement._lifeSpan = existing._lifeSpan;
      replacement.addEventListener("pointerenter", this.#onHoverNotification.bind(this));
      replacement.addEventListener("pointerleave", this.#onHoverNotification.bind(this));
    }
  }

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

  /**
   * 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 behavior.
   */
  async scrollBottom({popout=false, waitImages=false, scrollOptions={}}={}) {
    if ( !this.rendered ) return;
    const scroll = this.element.querySelector(".chat-scroll");
    if ( waitImages ) await this.constructor.waitForImages(scroll);
    scroll.scrollTop = 0x7fffffbf;
    if ( popout ) this.popout?.scrollBottom({waitImages, scrollOptions});
  }

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

  /**
   * Update the contents of a previously-posted message.
   * @param {ChatMessage} message  The ChatMessage instance to update.
   * @param {object} options
   * @param {boolean} [options.notify=false]  Trigger a notification which shows the log as having a new unread message.
   * @returns {Promise<void>}
   */
  async updateMessage(message, options={}) {
    return this.#renderingQueue.add(this.#updateMessage.bind(this), message, options);
  }

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

  /**
   * Update displayed timestamps for every displayed message in the chat log.
   * Timestamps are displayed in a humanized "time-since" format.
   */
  updateTimestamps() {
    for ( const li of document.querySelectorAll(".chat-message[data-message-id]") ) {
      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);
    }
  }

  /* -------------------------------------------- */
  /*  Private API                                 */
  /* -------------------------------------------- */

  /**
   * Check for notifications that should be automatically dismissed.
   */
  #checkNotificationLifeSpan() {
    let isPaused = false;
    const {NOTIFY_DURATION, NOTIFY_TICKER} = this.constructor;
    for ( const element of this.#notificationsElement.querySelectorAll(".message") ) {
      element._lifeSpan += NOTIFY_TICKER;
      if ( element.classList.contains("hovered") ) isPaused = true;
      if ( !isPaused && (element._lifeSpan >= NOTIFY_DURATION) ) this.#dismissNotification(element);
    }
  }

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

  /**
   * Delete a single message from the chat log.
   * @param {string} messageId                   The ID of the ChatMessage Document to remove from the log.
   * @param {object} [options]
   * @param {boolean} [options.deleteAll=false]  Delete all messages from the log.
   */
  #deleteMessage(messageId, {deleteAll=false}={}) {
    if ( !this.rendered ) return;

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

    // Get the message's element
    const li = this.element.querySelector(`.message[data-message-id="${messageId}"]`);
    if ( !li ) return;

    // Update the last index
    if ( deleteAll ) this.#lastId = null;
    else if ( messageId === this.#lastId ) this.#lastId = li.nextElementSibling?.dataset.messageId ?? null;

    // Remove the deleted message
    li.classList.add("deleting");
    li.animate(
      {height: [`${li.getBoundingClientRect().height}px`, "0"]},
      {duration: 100, easing: "ease"}
    ).finished.then(() => {
      li.remove();
      this.#onScrollLog();
      // Re-fit only once the removal animation is complete, so we have an accurate size.
      if ( this.isPopout ) this._refit();
    });

    if ( !this.isPopout ) {
      this.#notificationsElement.querySelector(`.message[data-message-id="${messageId}"]`)?.remove();
    }

    // Delete from the popout tab
    this.popout?.deleteMessage(messageId, {deleteAll});
    if ( !this.isPopout ) this.#overflowingDebounce();
  }

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

  /**
   * Dismiss a notification from the ChatLog.
   * @param {HTMLElement} element
   * @returns {Promise<void>}
   */
  async #dismissNotification(element) {
    element.classList.add("deleting");
    await element.animate({
      opacity: [1, 0],
      transform: ["translateY(0)", "translateY(-35px)"]
    }, {duration: 250, easing: "ease", fill: "forwards"}).finished;
    await element.animate({
      height: [`${element.getBoundingClientRect().height}px`, "0"]
    }, {duration: 100, easing: "ease"}).finished;
    element.remove();
  }

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

  /**
   * Post a chat card to the notification queue.
   * @param {ChatMessage} message             The message to render.
   * @param {object} [options]
   * @param {HTMLElement} [options.existing]  The corresponding element in the chat log, if it exists.
   * @returns {Promise<void>}
   */
  async #postNotification(message, {existing}={}) {
    const log = this.#notificationsElement.querySelector(".chat-log");
    const paused = log.querySelector(".message.hovered");
    this.#notificationsElement.classList.add("active");
    this.#notifyAlertDebounce();

    const dummy = document.createElement("li");
    dummy.classList.add("chat-message", "message");
    dummy.dataset.messageId = message.id;
    dummy.hidden = true;
    log.append(dummy);

    let element = await this.constructor.renderMessage(message, {canDelete: false, canClose: true});
    element.addEventListener("pointerenter", this.#onHoverNotification.bind(this));
    element.addEventListener("pointerleave", this.#onHoverNotification.bind(this));
    element.hidden = true;
    dummy.replaceWith(element);

    // Insert spacer element to animate gap for card to be inserted.
    const {height=130} = existing?.getBoundingClientRect() ?? {};
    const spacer = document.createElement("li");
    spacer.style.height = "0";
    spacer.classList.add("spacer");
    log.append(spacer);
    if ( !paused ) spacer.scrollIntoView();
    await spacer.animate(
      {height: ["0", `${height}px`]},
      {duration: 100, easing: "ease", fill: "forwards"}
    ).finished;

    // Insert the new card.
    spacer.remove();
    element = this.#notificationsElement.querySelector(`.message[data-message-id="${message.id}"]`);
    element.hidden = false;
    element.animate({
      opacity: [0, 1],
      transform: ["translateY(-35px)", "translateY(0)"]
    }, {duration: 250, easing: "ease", fill: "forwards"});
    element._lifeSpan = 0;
  }

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

  /**
   * Post a single chat message to the log.
   * @param {ChatMessage} message             The chat message.
   * @param {object} [options]
   * @param {string} [options.before]         An existing message ID to prepend the posted message to, by default the
   *                                          new message is appended to the end of the log.
   * @param {boolean} [options.notify=false]  Trigger a notification which shows the log as having a new unread message.
   * @param {boolean} [options.scroll]        Pass true to always scroll-to-bottom, or false to suppress this. Omitting
   *                                          this option preserves the default conditional scroll-to-bottom.
   * @returns {Promise<void>}                 A Promise which resolves once the message has been posted.
   */
  async #postOne(message, {before, notify=false, scroll}={}) {
    if ( !this.rendered || !message.visible ) return;
    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 log = this.element.querySelector(".chat-log");
    const html = await this.constructor.renderMessage(message);

    // Due to latency, a newer message might have been posted to the log. If so, place this message behind it.
    if ( !before ) {
      let newerMessage;
      for ( let i = log.children.length; i--; ) {
        const msg = game.messages.get(log.children[i].dataset.messageId);
        if ( !msg ) continue;
        if ( msg.timestamp <= message.timestamp ) break;
        newerMessage = msg;
      }
      before = newerMessage?.id;
    }

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

    // Otherwise, append the message to the bottom of the log
    else log.append(html);

    if ( scroll || ((scroll !== false) && !existing && (this.isAtBottom || (message.author.id === game.user.id))) ) {
      this.scrollBottom({waitImages: true});
    }

    // Append to notifications.
    if ( notify ) this.notify(message, {existing: html, newMessage: true});

    // Update popout tab
    if ( this.popout?.rendered ) await this.popout.postOne(message, {before, scroll, notify: false});
    if ( this.isPopout ) this._refit();
    else this.#overflowingDebounce();
  }

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

  /**
   * Update the contents of a previously-posted message.
   * @param {ChatMessage} message  The ChatMessage instance to update.
   * @param {object} options
   * @param {boolean} [options.notify=false]  Trigger a notification which shows the log as having a new unread message.
   * @returns {Promise<void>}
   */
  async #updateMessage(message, {notify=false}={}) {
    if ( !message.visible ) return this.#deleteMessage(message.id);
    const li = this.element.querySelector(`.message[data-message-id="${message.id}"]`);
    if ( li ) await this.#rerenderMessage(message, li);
    // A previously invisible message has become visible to this user.
    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});
    }

    if ( !this.isPopout ) {
      const existing = this.#notificationsElement.querySelector(`.message[data-message-id="${message.id}"]`);
      if ( existing ) await this.#rerenderMessage(message, existing, {canDelete: false, canClose: true});
    }

    if ( notify ) this.notify(message);

    // Update the popout tab
    if ( this.popout?.rendered ) await this.popout.updateMessage(message, {notify: false});
    if ( this.isPopout ) this._refit();
    else this.#overflowingDebounce();
  }

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

  /**
   * Handles chat message rendering during the ChatMessage#getHTML deprecation period. After that period ends, calls to
   * this method can be replaced by ChatMessage#renderHTML.
   * @param {ChatMessage} message  The chat message to render.
   * @param {object} [options]     Options forwarded to the render function.
   * @returns {Promise<HTMLElement>}
   * @throws {Error}               If the message's render methods do not return a usable result.
   */
  static async renderMessage(message, options) {
    const hasGetHTML = foundry.utils.getDefiningClass(message, "getHTML") !== ChatMessage;
    const hasRenderHTML = foundry.utils.getDefiningClass(message, "renderHTML") !== ChatMessage;
    /** @deprecated since v13 */
    if ( hasGetHTML && !hasRenderHTML ) {
      const html = await message.getHTML(options);
      if ( foundry.utils.getType(html) === "HTMLElement" ) return html;
      if ( foundry.utils.getType(html[0]) === "HTMLElement" ) return html[0];
      throw new Error(`Unable to render ChatMessage [${message.id}] as it did not return an HTMLElement or jQuery.`);
    }
    return message.renderHTML(options);
  }

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

  /**
   * Update the overflowing state of the chat scroll container.
   */
  #setOverflowing() {
    const scroll = this.element.querySelector(".chat-scroll");
    scroll.classList.toggle("overflowed", scroll.scrollHeight > scroll.offsetHeight);
  }

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

  /**
   * Determine whether the notifications pane should be visible.
   * @param {object} [options]
   * @param {boolean} [options.closing=false]  Whether the chat popout is closing.
   * @returns {boolean}
   * @protected
   */
  _shouldShowNotifications({closing=false}={}) {
    const {chatNotifications, uiScale} = game.settings.get("core", "uiConfig");

    // Case 1 - Chat notifications disabled.
    if ( (chatNotifications === "pip") || this.options.stream ) return false;

    // Case 2 - Chat tab visible in sidebar.
    if ( ui.sidebar.expanded && ui.chat.active ) return false;

    // Case 3 - Chat popout visible.
    if ( ui.chat.popout?.rendered && (!this.isPopout || !closing) ) return false;

    // Case 4 - Not enough viewport width available.
    const cameraDock = ui.webrtc.isVertical && !ui.webrtc.hidden;
    const viewportWidth = window.innerWidth / uiScale;
    const spaceRequired = 1024 + (ui.sidebar.expanded * 300) + (cameraDock * 264);
    return viewportWidth >= spaceRequired;
  }

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

  /**
   * Update notification display, based on interface state.
   * If the chat log is popped-out, embed chat input into it. Otherwise,
   * if the sidebar is expanded, and the chat log is the active tab, embed chat input into it. Otherwise,
   * embed chat input into the notifications area.
   * If the sidebar is expanded, and the chat log is the active tab, do not display notifications.
   * If the chat log is popped out, do not display notifications.
   * @param {(ApplicationRenderOptions|ApplicationClosingOptions) & { closing?: boolean }} [options]  Options which were
   *                                           passed to the render or close operation that triggered this method.
   * @param {boolean} [options.closing=false]  Whether this method has been triggered by the chat popout closing.
   * @fires {hookEvents:renderChatInput}
   * @internal
   */
  _toggleNotifications(options={}) {
    const {closing=false} = options;
    if ( ui.chat.popout?.rendered && !this.isPopout ) return;
    const notifications = ui.chat.#notificationsElement;
    const inputElement = ui.chat.#inputElement;
    const chatControls = ui.chat.#chatControls;
    const modeButtons = chatControls.querySelector("#message-modes");
    const log = notifications.querySelector(".chat-log");
    const embedInput = !this._shouldShowNotifications({closing});
    log.hidden = embedInput;
    modeButtons.classList.toggle("vertical", !embedInput);
    const previousParent = inputElement.parentElement;
    if ( game.user.isGM ) chatControls.querySelector(".control-buttons").hidden = !embedInput;
    if ( embedInput ) {
      const target = ui.chat.popout?.rendered && !closing ? ui.chat.popout : ui.chat;
      target.element.querySelector(".chat-form").append(chatControls, inputElement);
      inputElement.focus();
    }
    else if ( !notifications.contains(inputElement) ) notifications.append(inputElement, chatControls);
    Hooks.callAll("renderChatInput", this, {
      "#chat-message": inputElement,
      "#chat-controls": chatControls,
      "#chat-notifications": notifications,
      "#message-modes": modeButtons,
      "#roll-privacy": modeButtons /** @deprecated since v14 */
    }, {previousParent}, options);
    this.#offsetHotbar(!embedInput);
    if ( this.#isAtBottom ) this.scrollBottom();
  }

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

  /**
   * Handle updating the chat message mode display.
   * @internal
   */
  _updateMessageMode() {
    if ( this.isPopout ) return;
    const mode = game.settings.get("core", "messageMode");
    for ( const button of document.getElementById("message-modes").querySelectorAll("button") ) {
      button.ariaPressed = `${mode === button.dataset.mode}`;
    }
  }

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

  /**
   * Handle offsetting the hotbar based on the chat notification position.
   * @param {boolean} notifications  Whether the chat notifications are active.
   */
  #offsetHotbar(notifications) {
    if ( this.options.stream ) return;
    const hotbar = document.getElementById("hotbar");
    const currentOffset = Number(hotbar.style.getPropertyValue("--offset").replace(/px$/, "")) || 0;
    hotbar.style.transition = "none";
    hotbar.classList.remove("offset");
    hotbar.style.removeProperty("--offset");
    hotbar.style.transition = "";
    if ( !notifications ) return;
    const {uiScale} = game.settings.get("core", "uiConfig");
    // Calculate an offset to keep the hotbar visually centered.
    const bb = hotbar.getBoundingClientRect();
    // Hard-code this number for now since the sidebar is mid-animation so we can't get an accurate width.
    const rightWidth = (380 + (300 * ui.sidebar.expanded)) / uiScale;
    const offset = currentOffset + (window.innerWidth / uiScale) - bb.right - rightWidth;
    if ( offset > 0 ) return; // Don't move closer to the sidebar.
    hotbar.classList.add("offset");
    hotbar.style.setProperty("--offset", `${offset}px`);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * An enumeration of regular expression patterns used to match chat messages.
   * @enum {RegExp}
   * @deprecated since v14
   * @ignore
   */
  static MESSAGE_PATTERNS = (() => {
    const target = Object.fromEntries(Object.entries(this.CHAT_COMMANDS).map(([k, v]) => [k, v.rgx]));
    return new Proxy(target, {
      get(target, key, receiver) {
        foundry.utils.logCompatibilityWarning(
          "ChatLog.MESSAGE_PATTERNS is deprecated. Use ChatLog.CHAT_COMMANDS instead.",
          {since: 14, until: 16, once: true}
        );
        return Reflect.get(target, key, receiver);
      },
      set(target, key, value, receiver) {
        foundry.utils.logCompatibilityWarning(
          "Writing to ChatLog.MESSAGE_PATTERNS is deprecated. Register commands via ChatLog.CHAT_COMMANDS instead.",
          {since: 14, until: 16, once: true}
        );
        Reflect.set(target, key, value, receiver);
        ChatLog.#legacyPatterns[key] = value;
        return true;
      }
    });
  })();

  /**
   * The set of commands that can be processed over multiple lines.
   * @type {Set<string>}
   * @deprecated since v14
   * @ignore
   */
  static MULTILINE_COMMANDS = new Set();

}
