/** @module client */

/**
 * The string prefix used to prepend console logging
 * @type {string}
 */
const vtt = globalThis.vtt = "Foundry VTT";

/**
 * The singleton Game instance
 * @type {Game}
 */
let game = globalThis.game = {};

// Utilize SmoothGraphics by default
PIXI.LegacyGraphics = PIXI.Graphics;
PIXI.Graphics = PIXI.smooth.SmoothGraphics;

/**
 * The global boolean for whether the EULA is signed
 */
globalThis.SIGNED_EULA = SIGNED_EULA;

/**
 * The global route prefix which is applied to this game
 * @type {string}
 */
globalThis.ROUTE_PREFIX = ROUTE_PREFIX;

/**
 * Critical server-side startup messages which need to be displayed to the client.
 * @type {Array<{type: string, message: string, options: object}>}
 */
globalThis.MESSAGES = MESSAGES || [];

/**
 * A collection of application instances
 * @type {Record<string, Application>}
 * @alias ui
 */
globalThis.ui = {
  windows: {}
};

/**
 * The client side console logger
 * @type {Console}
 * @alias logger
 */
logger = globalThis.logger = console;

/**
 * The Color management and manipulation class
 * @alias {foundry.utils.Color}
 */
globalThis.Color = foundry.utils.Color;

/**
 * A helper class to manage requesting clipboard permissions and provide common functionality for working with the
 * clipboard.
 */
class ClipboardHelper {
  constructor() {
    if ( game.clipboard instanceof this.constructor ) {
      throw new Error("You may not re-initialize the singleton ClipboardHelper. Use game.clipboard instead.");
    }
  }

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

  /**
   * Copies plain text to the clipboard in a cross-browser compatible way.
   * @param {string} text  The text to copy.
   * @returns {Promise<void>}
   */
  async copyPlainText(text) {
    // The clipboard-write permission name is not supported in Firefox.
    try {
      const result = await navigator.permissions.query({name: "clipboard-write"});
      if ( ["granted", "prompt"].includes(result.state) ) {
        return navigator.clipboard.writeText(text);
      }
    } catch(err) {}

    // Fallback to deprecated execCommand here if writeText is not supported in this browser or security context.
    document.addEventListener("copy", event => {
      event.clipboardData.setData("text/plain", text);
      event.preventDefault();
    }, {once: true});
    document.execCommand("copy");
  }
}

/**
 * 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);
  }
}

/**
 * Management class for Gamepad events
 */
class GamepadManager {
  constructor() {
    this._gamepadPoller = null;

    /**
     * The connected Gamepads
     * @type {Map<string, ConnectedGamepad>}
     * @private
     */
    this._connectedGamepads = new Map();
  }

  /**
   * How often Gamepad polling should check for button presses
   * @type {number}
   */
  static GAMEPAD_POLLER_INTERVAL_MS = 100;

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

  /**
   * Begin listening to gamepad events.
   * @internal
   */
  _activateListeners() {
    window.addEventListener("gamepadconnected", this._onGamepadConnect.bind(this));
    window.addEventListener("gamepaddisconnected", this._onGamepadDisconnect.bind(this));
  }

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

  /**
   * Handles a Gamepad Connection event, adding its info to the poll list
   * @param {GamepadEvent} event The originating Event
   * @private
   */
  _onGamepadConnect(event) {
    if ( CONFIG.debug.gamepad ) console.log(`Gamepad ${event.gamepad.id} connected`);
    this._connectedGamepads.set(event.gamepad.id, {
      axes: new Map(),
      activeButtons: new Set()
    });
    if ( !this._gamepadPoller ) this._gamepadPoller = setInterval(() => {
      this._pollGamepads()
    }, GamepadManager.GAMEPAD_POLLER_INTERVAL_MS);
    // Immediately poll to try and capture the action that connected the Gamepad
    this._pollGamepads();
  }

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

  /**
   * Handles a Gamepad Disconnect event, removing it from consideration for polling
   * @param {GamepadEvent} event The originating Event
   * @private
   */
  _onGamepadDisconnect(event) {
    if ( CONFIG.debug.gamepad ) console.log(`Gamepad ${event.gamepad.id} disconnected`);
    this._connectedGamepads.delete(event.gamepad.id);
    if ( this._connectedGamepads.length === 0 ) {
      clearInterval(this._gamepadPoller);
      this._gamepadPoller = null;
    }
  }

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

  /**
   * Polls all Connected Gamepads for updates. If they have been updated, checks status of Axis and Buttons,
   * firing off Keybinding Contexts as appropriate
   * @private
   */
  _pollGamepads() {
    // Joysticks are not very precise and range from -1 to 1, so we need to ensure we avoid drift due to low (but not zero) values
    const AXIS_PRECISION = 0.15;
    const MAX_AXIS = 1;
    for ( let gamepad of navigator.getGamepads() ) {
      if ( !gamepad || !this._connectedGamepads.has(gamepad?.id) ) continue;
      const id = gamepad.id;
      let gamepadData = this._connectedGamepads.get(id);

      // Check Active Axis
      for ( let x = 0; x < gamepad.axes.length; x++ ) {
        let axisValue = gamepad.axes[x];

        // Verify valid input and handle inprecise values
        if ( Math.abs(axisValue) > MAX_AXIS ) continue;
        if ( Math.abs(axisValue) <= AXIS_PRECISION ) axisValue = 0;

        // Store Axis data per Joystick as Numbers
        const joystickId = `${id}_AXIS${x}`;
        const priorValue = gamepadData.axes.get(joystickId) ?? 0;

        // An Axis exists from -1 to 1, with 0 being the center.
        // We split an Axis into Negative and Positive zones to differentiate pressing it left / right and up / down
        if ( axisValue !== 0 ) {
          const sign = Math.sign(axisValue);
          const repeat = sign === Math.sign(priorValue);
          const emulatedKey = `${joystickId}_${sign > 0 ? "POSITIVE" : "NEGATIVE"}`;
          this._handleGamepadInput(emulatedKey, false, repeat);
        }
        else if ( priorValue !== 0 ) {
          const sign = Math.sign(priorValue);
          const emulatedKey = `${joystickId}_${sign > 0 ? "POSITIVE" : "NEGATIVE"}`;
          this._handleGamepadInput(emulatedKey, true);
        }

        // Update value
        gamepadData.axes.set(joystickId, axisValue);
      }

      // Check Pressed Buttons
      for ( let x = 0; x < gamepad.buttons.length; x++ ) {
        const button = gamepad.buttons[x];
        const buttonId = `${id}_BUTTON${x}_PRESSED`;
        if ( button.pressed ) {
          const repeat = gamepadData.activeButtons.has(buttonId);
          if ( !repeat ) gamepadData.activeButtons.add(buttonId);
          this._handleGamepadInput(buttonId, false, repeat);
        }
        else if ( gamepadData.activeButtons.has(buttonId) ) {
          gamepadData.activeButtons.delete(buttonId);
          this._handleGamepadInput(buttonId, true);
        }
      }
    }
  }

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

  /**
   * Converts a Gamepad Input event into a KeyboardEvent, then fires it
   * @param {string} gamepadId  The string representation of the Gamepad Input
   * @param {boolean} up        True if the Input is pressed or active
   * @param {boolean} repeat    True if the Input is being held
   * @private
   */
  _handleGamepadInput(gamepadId, up, repeat = false) {
    const key = gamepadId.replaceAll(" ", "").toUpperCase().trim();
    const event = new KeyboardEvent(`key${up ? "up" : "down"}`, {code: key, bubbles: true});
    window.dispatchEvent(event);
    $(".binding-input:focus").get(0)?.dispatchEvent(event);
  }
}

/**
 * @typedef {object} HookedFunction
 * @property {string} hook
 * @property {number} id
 * @property {Function} fn
 * @property {boolean} once
 */

/**
 * A simple event framework used throughout Foundry Virtual Tabletop.
 * When key actions or events occur, a "hook" is defined where user-defined callback functions can execute.
 * This class manages the registration and execution of hooked callback functions.
 */
class Hooks {

  /**
   * A mapping of hook events which have functions registered to them.
   * @type {Record<string, HookedFunction[]>}
   */
  static get events() {
    return this.#events;
  }

  /**
   * @type {Record<string, HookedFunction[]>}
   * @private
   * @ignore
   */
  static #events = {};

  /**
   * A mapping of hooked functions by their assigned ID
   * @type {Map<number, HookedFunction>}
   */
  static #ids = new Map();

  /**
   * An incrementing counter for assigned hooked function IDs
   * @type {number}
   */
  static #id = 1;

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

  /**
   * Register a callback handler which should be triggered when a hook is triggered.
   * @param {string} hook     The unique name of the hooked event
   * @param {Function} fn     The callback function which should be triggered when the hook event occurs
   * @param {object} options  Options which customize hook registration
   * @param {boolean} options.once  Only trigger the hooked function once
   * @returns {number}      An ID number of the hooked function which can be used to turn off the hook later
   */
  static on(hook, fn, {once=false}={}) {
    console.debug(`${vtt} | Registered callback for ${hook} hook`);
    const id = this.#id++;
    if ( !(hook in this.#events) ) {
      Object.defineProperty(this.#events, hook, {value: [], writable: false});
    }
    const entry = {hook, id, fn, once};
    this.#events[hook].push(entry);
    this.#ids.set(id, entry);
    return id;
  }

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

  /**
   * Register a callback handler for an event which is only triggered once the first time the event occurs.
   * An alias for Hooks.on with {once: true}
   * @param {string} hook   The unique name of the hooked event
   * @param {Function} fn   The callback function which should be triggered when the hook event occurs
   * @returns {number}      An ID number of the hooked function which can be used to turn off the hook later
   */
  static once(hook, fn) {
    return this.on(hook, fn, {once: true});
  }

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

  /**
   * Unregister a callback handler for a particular hook event
   * @param {string} hook           The unique name of the hooked event
   * @param {Function|number} fn    The function, or ID number for the function, that should be turned off
   */
  static off(hook, fn) {
    let entry;

    // Provided an ID
    if ( typeof fn === "number" ) {
      const id = fn;
      entry = this.#ids.get(id);
      if ( !entry ) return;
      this.#ids.delete(id);
      const event = this.#events[entry.hook];
      event.findSplice(h => h.id === id);
    }

    // Provided a Function
    else {
      const event = this.#events[hook];
      const entry = event.findSplice(h => h.fn === fn);
      if ( !entry ) return;
      this.#ids.delete(entry.id);
    }
    console.debug(`${vtt} | Unregistered callback for ${hook} hook`);
  }

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

  /**
   * Call all hook listeners in the order in which they were registered
   * Hooks called this way can not be handled by returning false and will always trigger every hook callback.
   *
   * @param {string} hook   The hook being triggered
   * @param {...*} args     Arguments passed to the hook callback functions
   * @returns {boolean}     Were all hooks called without execution being prevented?
   */
  static callAll(hook, ...args) {
    if ( CONFIG.debug.hooks ) {
      console.log(`DEBUG | Calling ${hook} hook with args:`);
      console.log(args);
    }
    if ( !(hook in this.#events) ) return true;
    for ( const entry of Array.from(this.#events[hook]) ) {
      this.#call(entry, args);
    }
    return true;
  }

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

  /**
   * Call hook listeners in the order in which they were registered.
   * Continue calling hooks until either all have been called or one returns false.
   *
   * Hook listeners which return false denote that the original event has been adequately handled and no further
   * hooks should be called.
   *
   * @param {string} hook   The hook being triggered
   * @param {...*} args     Arguments passed to the hook callback functions
   * @returns {boolean}     Were all hooks called without execution being prevented?
   */
  static call(hook, ...args) {
    if ( CONFIG.debug.hooks ) {
      console.log(`DEBUG | Calling ${hook} hook with args:`);
      console.log(args);
    }
    if ( !(hook in this.#events) ) return true;
    for ( const entry of Array.from(this.#events[hook]) ) {
      let callAdditional = this.#call(entry, args);
      if ( callAdditional === false ) return false;
    }
    return true;
  }

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

  /**
   * Call a hooked function using provided arguments and perhaps unregister it.
   * @param {HookedFunction} entry    The hooked function entry
   * @param {any[]} args              Arguments to be passed
   * @private
   */
  static #call(entry, args) {
    const {hook, id, fn, once} = entry;
    if ( once ) this.off(hook, id);
    try {
      return entry.fn(...args);
    } catch(err) {
      const msg = `Error thrown in hooked function '${fn?.name}' for hook '${hook}'`;
      console.warn(`${vtt} | ${msg}`);
      if ( hook !== "error" ) this.onError("Hooks.#call", err, {msg, hook, fn, log: "error"});
    }
  }

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

  /**
   * Notify subscribers that an error has occurred within foundry.
   * @param {string} location                The method where the error was caught.
   * @param {Error} error                    The error.
   * @param {object} [options={}]            Additional options to configure behaviour.
   * @param {string} [options.msg=""]        A message which should prefix the resulting error or notification.
   * @param {?string} [options.log=null]     The level at which to log the error to console (if at all).
   * @param {?string} [options.notify=null]  The level at which to spawn a notification in the UI (if at all).
   * @param {object} [options.data={}]       Additional data to pass to the hook subscribers.
   */
  static onError(location, error, {msg="", notify=null, log=null, ...data}={}) {
    if ( !(error instanceof Error) ) return;
    if ( msg ) error = new Error(`${msg}. ${error.message}`, { cause: error });
    if ( log ) console[log]?.(error);
    if ( notify ) ui.notifications[notify]?.(msg || error.message);
    Hooks.callAll("error", location, error, data);
  }
}

/**
 * A helper class to provide common functionality for working with Image objects
 */
class ImageHelper {

  /**
   * Create thumbnail preview for a provided image path.
   * @param {string|PIXI.DisplayObject} src   The URL or display object of the texture to render to a thumbnail
   * @param {object} options    Additional named options passed to the compositeCanvasTexture function
   * @param {number} [options.width]        The desired width of the resulting thumbnail
   * @param {number} [options.height]       The desired height of the resulting thumbnail
   * @param {number} [options.tx]           A horizontal transformation to apply to the provided source
   * @param {number} [options.ty]           A vertical transformation to apply to the provided source
   * @param {boolean} [options.center]      Whether to center the object within the thumbnail
   * @param {string} [options.format]       The desired output image format
   * @param {number} [options.quality]      The desired output image quality
   * @returns {Promise<object>}  The parsed and converted thumbnail data
   */
  static async createThumbnail(src, {width, height, tx, ty, center, format, quality}) {
    if ( !src ) return null;

    // Load the texture and create a Sprite
    let object = src;
    if ( !(src instanceof PIXI.DisplayObject) ) {
      const texture = await loadTexture(src);
      object = PIXI.Sprite.from(texture);
    }

    // Reduce to the smaller thumbnail texture
    if ( !canvas.ready && canvas.initializing ) await canvas.initializing;
    const reduced = this.compositeCanvasTexture(object, {width, height, tx, ty, center});
    const thumb = await this.textureToImage(reduced, {format, quality});
    reduced.destroy(true);

    // Return the image data
    return { src, texture: reduced, thumb, width: object.width, height: object.height };
  }

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

  /**
   * Test whether a source file has a supported image extension type
   * @param {string} src      A requested image source path
   * @returns {boolean}       Does the filename end with a valid image extension?
   */
  static hasImageExtension(src) {
    return foundry.data.validators.hasFileExtension(src, Object.keys(CONST.IMAGE_FILE_EXTENSIONS));
  }

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

  /**
   * Composite a canvas object by rendering it to a single texture
   *
   * @param {PIXI.DisplayObject} object   The object to render to a texture
   * @param {object} [options]            Options which configure the resulting texture
   * @param {number} [options.width]        The desired width of the output texture
   * @param {number} [options.height]       The desired height of the output texture
   * @param {number} [options.tx]           A horizontal translation to apply to the object
   * @param {number} [options.ty]           A vertical translation to apply to the object
   * @param {boolean} [options.center]      Center the texture in the rendered frame?
   *
   * @returns {PIXI.Texture}              The composite Texture object
   */
  static compositeCanvasTexture(object, {width, height, tx=0, ty=0, center=true}={}) {
    if ( !canvas.app?.renderer ) throw new Error("Unable to compose texture because there is no game canvas");
    width = width ?? object.width;
    height = height ?? object.height;

    // Downscale the object to the desired thumbnail size
    const currentRatio = object.width / object.height;
    const targetRatio = width / height;
    const s = currentRatio > targetRatio ? (height / object.height) : (width / object.width);

    // Define a transform matrix
    const transform = PIXI.Matrix.IDENTITY.clone();
    transform.scale(s, s);

    // Translate position
    if ( center ) {
      tx = (width - (object.width * s)) / 2;
      ty = (height - (object.height * s)) / 2;
    } else {
      tx *= s;
      ty *= s;
    }
    transform.translate(tx, ty);

    // Create and render a texture with the desired dimensions
    const renderTexture = PIXI.RenderTexture.create({
      width: width,
      height: height,
      scaleMode: PIXI.SCALE_MODES.LINEAR,
      resolution: canvas.app.renderer.resolution
    });
    canvas.app.renderer.render(object, {
      renderTexture,
      transform
    });
    return renderTexture;
  }

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

  /**
   * Extract a texture to a base64 PNG string
   * @param {PIXI.Texture} texture      The texture object to extract
   * @param {object} options
   * @param {string} [options.format]   Image format, e.g. "image/jpeg" or "image/webp".
   * @param {number} [options.quality]  JPEG or WEBP compression from 0 to 1. Default is 0.92.
   * @returns {Promise<string>}         A base64 png string of the texture
   */
  static async textureToImage(texture, {format, quality}={}) {
    const s = new PIXI.Sprite(texture);
    return canvas.app.renderer.extract.base64(s, format, quality);
  }

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

  /**
   * Asynchronously convert a DisplayObject container to base64 using Canvas#toBlob and FileReader
   * @param {PIXI.DisplayObject} target     A PIXI display object to convert
   * @param {string} type                   The requested mime type of the output, default is image/png
   * @param {number} quality                A number between 0 and 1 for image quality if image/jpeg or image/webp
   * @returns {Promise<string>}             A processed base64 string
   */
  static async pixiToBase64(target, type, quality) {
    const extracted = canvas.app.renderer.extract.canvas(target);
    return this.canvasToBase64(extracted, type, quality);
  }

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

  /**
   * Asynchronously convert a canvas element to base64.
   * @param {HTMLCanvasElement} canvas
   * @param {string} [type="image/png"]
   * @param {number} [quality]
   * @returns {Promise<string>} The base64 string of the canvas.
   */
  static async canvasToBase64(canvas, type, quality) {
    return new Promise((resolve, reject) => {
      canvas.toBlob(blob => {
        const reader = new FileReader();
        reader.onload = () => resolve(reader.result);
        reader.onerror = reject;
        reader.readAsDataURL(blob);
      }, type, quality);
    });
  }

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

  /**
   * Upload a base64 image string to a persisted data storage location
   * @param {string} base64       The base64 string
   * @param {string} fileName     The file name to upload
   * @param {string} filePath     The file path where the file should be uploaded
   * @param {object} [options]    Additional options which affect uploading
   * @param {string} [options.storage=data]   The data storage location to which the file should be uploaded
   * @param {string} [options.type]           The MIME type of the file being uploaded
   * @param {boolean} [options.notify=true]   Display a UI notification when the upload is processed.
   * @returns {Promise<object>}   A promise which resolves to the FilePicker upload response
   */
  static async uploadBase64(base64, fileName, filePath, {storage="data", type, notify=true}={}) {
    type ||= base64.split(";")[0].split("data:")[1];
    const blob = await fetch(base64).then(r => r.blob());
    const file = new File([blob], fileName, {type});
    return FilePicker.upload(storage, filePath, file, {}, { notify });
  }

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

  /**
   * Create a canvas element containing the pixel data.
   * @param {Uint8ClampedArray} pixels              Buffer used to create the image data.
   * @param {number} width                          Buffered image width.
   * @param {number} height                         Buffered image height.
   * @param {object} options
   * @param {HTMLCanvasElement} [options.element]   The element to use.
   * @param {number} [options.ew]                   Specified width for the element (default to buffer image width).
   * @param {number} [options.eh]                   Specified height for the element (default to buffer image height).
   * @returns {HTMLCanvasElement}
   */
  static pixelsToCanvas(pixels, width, height, {element, ew, eh}={}) {
    // If an element is provided, use it. Otherwise, create a canvas element
    element ??= document.createElement("canvas");

    // Assign specific element width and height, if provided. Otherwise, assign buffered image dimensions
    element.width = ew ?? width;
    element.height = eh ?? height;

    // Get the context and create a new image data with the buffer
    const context = element.getContext("2d");
    const imageData = new ImageData(pixels, width, height);
    context.putImageData(imageData, 0, 0);

    return element;
  }
}

/**
 * An object structure of document types at the top level, with a count of different sub-types for that document type.
 * @typedef {Record<string, Record<string, number>>} ModuleSubTypeCounts
 */

/**
 * A class responsible for tracking issues in the current world.
 */
class ClientIssues {
  /**
   * Keep track of valid Documents in the world that are using module-provided sub-types.
   * @type {Map<string, ModuleSubTypeCounts>}
   */
  #moduleTypeMap = new Map();

  /**
   * Keep track of document validation failures.
   * @type {object}
   */
  #documentValidationFailures = {};

  /**
   * @typedef {object} UsabilityIssue
   * @property {string} message   The pre-localized message to display in relation to the usability issue.
   * @property {string} severity  The severity of the issue, either "error", "warning", or "info".
   * @property {object} [params]  Parameters to supply to the localization.
   */

  /**
   * Keep track of any usability issues related to browser or technology versions.
   * @type {Record<string, UsabilityIssue>}
   */
  #usabilityIssues = {};

  /**
   * The minimum supported resolution.
   * @type {{WIDTH: number, HEIGHT: number}}
   */
  static #MIN_RESOLUTION = {WIDTH: 1024, HEIGHT: 700};

  /**
   * @typedef {object} BrowserTest
   * @property {number} minimum  The minimum supported version for this browser.
   * @property {RegExp} match    A regular expression to match the browser against the user agent string.
   * @property {string} message  A message to display if the user's browser version does not meet the minimum.
   */

  /**
   * The minimum supported client versions.
   * @type {Record<string, BrowserTest>}
   */
  static #BROWSER_TESTS = {
    Electron: {
      minimum: 29,
      match: /Electron\/(\d+)\./,
      message: "ERROR.ElectronVersion"
    },
    Chromium: {
      minimum: 105,
      match: /Chrom(?:e|ium)\/(\d+)\./,
      message: "ERROR.BrowserVersion"
    },
    Firefox: {
      minimum: 121,
      match: /Firefox\/(\d+)\./,
      message: "ERROR.BrowserVersion"
    },
    Safari: {
      minimum: 15.4,
      match: /Version\/(\d+)\..*Safari\//,
      message: "ERROR.BrowserVersion"
    }
  };

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

  /**
   * Add a Document to the count of module-provided sub-types.
   * @param {string} documentName                The Document name.
   * @param {string} subType                     The Document's sub-type.
   * @param {object} [options]
   * @param {boolean} [options.decrement=false]  Decrement the counter rather than incrementing it.
   */
  #countDocumentSubType(documentName, subType, {decrement=false}={}) {
    if ( !((typeof subType === "string") && subType.includes(".")) ) return;
    const [moduleId, ...rest] = subType.split(".");
    subType = rest.join(".");
    if ( !this.#moduleTypeMap.has(moduleId) ) this.#moduleTypeMap.set(moduleId, {});
    const counts = this.#moduleTypeMap.get(moduleId);
    const types = counts[documentName] ??= {};
    types[subType] ??= 0;
    if ( decrement ) types[subType] = Math.max(types[subType] - 1, 0);
    else types[subType]++;
  }

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

  /**
   * Detect the user's browser and display a notification if it is below the minimum required version.
   */
  #detectBrowserVersion() {
    for ( const [browser, {minimum, match, message}] of Object.entries(ClientIssues.#BROWSER_TESTS) ) {
      const [, version] = navigator.userAgent.match(match) ?? [];
      if ( !Number.isNumeric(version) ) continue;
      if ( Number(version) < minimum ) {
        const err = game.i18n.format(message, {browser, version, minimum});
        ui.notifications?.error(err, {permanent: true, console: true});
        this.#usabilityIssues.browserVersionIncompatible = {
          message,
          severity: "error",
          params: {browser, version, minimum}
        };
      }
      break;
    }
  }

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

  /**
   * Record a reference to a resolution notification ID so that we can remove it if the problem is remedied.
   * @type {number}
   */
  #resolutionTooLowNotification;

  /**
   * Detect the user's resolution and display a notification if it is too small.
   */
  #detectResolution() {
    const {WIDTH: reqWidth, HEIGHT: reqHeight} = ClientIssues.#MIN_RESOLUTION;
    const {innerWidth: width, innerHeight: height} = window;
    if ( (height < reqHeight) || (width < reqWidth) ) {

      // Display a permanent error notification
      if ( ui.notifications && !this.#resolutionTooLowNotification ) {
        this.#resolutionTooLowNotification = ui.notifications.error(game.i18n.format("ERROR.LowResolution", {
          width, reqWidth, height, reqHeight
        }), {permanent: true});
      }

      // Record the usability issue
      this.#usabilityIssues.resolutionTooLow = {
        message: "ERROR.LowResolution",
        severity: "error",
        params: {width, reqWidth, height, reqHeight}
      };
    }

    // Remove an error notification if present
    else {
      if ( this.#resolutionTooLowNotification ) {
        this.#resolutionTooLowNotification = ui.notifications.remove(this.#resolutionTooLowNotification);
      }
      delete this.#usabilityIssues.resolutionTooLow;
    }
  }

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

  /**
   * Detect and display warnings for known performance issues which may occur due to the user's hardware or browser
   * configuration.
   * @internal
   */
  _detectWebGLIssues() {
    const context = canvas.app.renderer.context;
    try {
      const rendererInfo = SupportDetails.getWebGLRendererInfo(context.gl);
      if ( /swiftshader/i.test(rendererInfo) ) {
        ui.notifications.warn("ERROR.NoHardwareAcceleration", {localize: true, permanent: true});
        this.#usabilityIssues.hardwareAccel = {message: "ERROR.NoHardwareAcceleration", severity: "error"};
      }
    } catch ( err ) {
      ui.notifications.warn("ERROR.RendererNotDetected", {localize: true, permanent: true});
      this.#usabilityIssues.noRenderer = {message: "ERROR.RendererNotDetected", severity: "warning"};
    }

    // Verify that WebGL2 is being used.
    if ( !canvas.supported.webGL2 ) {
      ui.notifications.error("ERROR.NoWebGL2", {localize: true, permanent: true});
      this.#usabilityIssues.webgl2 = {message: "ERROR.NoWebGL2", severity: "error"};
    }
  }

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

  /**
   * Add an invalid Document to the module-provided sub-type counts.
   * @param {typeof Document} cls                The Document class.
   * @param {object} source                      The Document's source data.
   * @param {object} [options]
   * @param {boolean} [options.decrement=false]  Decrement the counter rather than incrementing it.
   * @internal
   */
  _countDocumentSubType(cls, source, options={}) {
    if ( cls.hasTypeData ) this.#countDocumentSubType(cls.documentName, source.type, options);
    for ( const [embeddedName, field] of Object.entries(cls.hierarchy) ) {
      if ( !(field instanceof foundry.data.fields.EmbeddedCollectionField) ) continue;
      for ( const embedded of source[embeddedName] ) {
        this._countDocumentSubType(field.model, embedded, options);
      }
    }
  }

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

  /**
   * Track a validation failure that occurred in a WorldCollection.
   * @param {WorldCollection} collection      The parent collection.
   * @param {object} source                   The Document's source data.
   * @param {DataModelValidationError} error  The validation error.
   * @internal
   */
  _trackValidationFailure(collection, source, error) {
    if ( !(collection instanceof WorldCollection) ) return;
    if ( !(error instanceof foundry.data.validation.DataModelValidationError) ) return;
    const documentName = collection.documentName;
    this.#documentValidationFailures[documentName] ??= {};
    this.#documentValidationFailures[documentName][source._id] = {name: source.name, error};
  }

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

  /**
   * Detect and record certain usability error messages which are likely to result in the user having a bad experience.
   * @internal
   */
  _detectUsabilityIssues() {
    this.#detectResolution();
    this.#detectBrowserVersion();
    window.addEventListener("resize", foundry.utils.debounce(this.#detectResolution.bind(this), 250), {passive: true});
  }

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

  /**
   * Get the Document sub-type counts for a given module.
   * @param {Module|string} module  The module or its ID.
   * @returns {ModuleSubTypeCounts}
   */
  getSubTypeCountsFor(module) {
    return this.#moduleTypeMap.get(module.id ?? module);
  }

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

  /**
   * Retrieve all sub-type counts in the world.
   * @returns {Iterator<string, ModuleSubTypeCounts>}
   */
  getAllSubTypeCounts() {
    return this.#moduleTypeMap.entries();
  }

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

  /**
   * Retrieve the tracked validation failures.
   * @returns {object}
   */
  get validationFailures() {
    return this.#documentValidationFailures;
  }

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

  /**
   * Retrieve the tracked usability issues.
   * @returns {Record<string, UsabilityIssue>}
   */
  get usabilityIssues() {
    return this.#usabilityIssues;
  }

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

  /**
   * @typedef {object} PackageCompatibilityIssue
   * @property {string[]} error    Error messages.
   * @property {string[]} warning  Warning messages.
   */

  /**
   * Retrieve package compatibility issues.
   * @returns {Record<string, PackageCompatibilityIssue>}
   */
  get packageCompatibilityIssues() {
    return game.data.packageWarnings;
  }
}

/**
 * A class responsible for managing defined game keybinding.
 * Each keybinding is a string key/value pair belonging to a certain namespace and a certain store scope.
 *
 * When Foundry Virtual Tabletop is initialized, a singleton instance of this class is constructed within the global
 * Game object as as game.keybindings.
 *
 * @see {@link Game#keybindings}
 * @see {@link SettingKeybindingConfig}
 * @see {@link KeybindingsConfig}
 */
class ClientKeybindings {
  constructor() {

    /**
     * Registered Keybinding actions
     * @type {Map<string, KeybindingActionConfig>}
     */
    this.actions = new Map();

    /**
     * A mapping of a string key to possible Actions that might execute off it
     * @type {Map<string, KeybindingAction[]>}
     */
    this.activeKeys = new Map();

    /**
     * A stored cache of Keybind Actions Ids to Bindings
     * @type {Map<string, KeybindingActionBinding[]>}
     */
    this.bindings = undefined;

    /**
     * A count of how many registered keybindings there are
     * @type {number}
     * @private
     */
    this._registered = 0;

    /**
     * A timestamp which tracks the last time a pan operation was performed
     * @type {number}
     * @private
     */
    this._moveTime = 0;
  }

  static MOVEMENT_DIRECTIONS = {
    UP: "up",
    LEFT: "left",
    DOWN: "down",
    RIGHT: "right"
  };

  static ZOOM_DIRECTIONS = {
    IN: "in",
    OUT: "out"
  };

  /**
   * An alias of the movement key set tracked by the keyboard
   * @returns {Set<string>}>
   */
  get moveKeys() {
    return game.keyboard.moveKeys;
  }

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

  /**
   * Initializes the keybinding values for all registered actions
   */
  initialize() {

    // Create the bindings mapping for all actions which have been registered
    this.bindings = new Map(Object.entries(game.settings.get("core", "keybindings")));
    for ( let k of Array.from(this.bindings.keys()) ) {
      if ( !this.actions.has(k) ) this.bindings.delete(k);
    }

    // Register bindings for all actions
    for ( let [action, config] of this.actions) {
      let bindings = config.uneditable;
      bindings = config.uneditable.concat(this.bindings.get(action) ?? config.editable);
      this.bindings.set(action, bindings);
    }

    // Create a mapping of keys which trigger actions
    this.activeKeys = new Map();
    for ( let [key, action] of this.actions ) {
      let bindings = this.bindings.get(key);
      for ( let binding of bindings ) {
        if ( !binding ) continue;
        if ( !this.activeKeys.has(binding.key) ) this.activeKeys.set(binding.key, []);
        let actions = this.activeKeys.get(binding.key);
        actions.push({
          action: key,
          key: binding.key,
          name: action.name,
          requiredModifiers: binding.modifiers,
          optionalModifiers: action.reservedModifiers,
          onDown: action.onDown,
          onUp: action.onUp,
          precedence: action.precedence,
          order: action.order,
          repeat: action.repeat,
          restricted: action.restricted
        });
        this.activeKeys.set(binding.key, actions.sort(this.constructor._compareActions));
      }
    }
  }

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

  /**
   * Register a new keybinding
   *
   * @param {string} namespace                  The namespace the Keybinding Action belongs to
   * @param {string} action                     A unique machine-readable id for the Keybinding Action
   * @param {KeybindingActionConfig} data       Configuration for keybinding data
   *
   * @example Define a keybinding which shows a notification
   * ```js
   * game.keybindings.register("myModule", "showNotification", {
   *   name: "My Settings Keybinding",
   *   hint: "A description of what will occur when the Keybinding is executed.",
   *   uneditable: [
   *     {
   *       key: "Digit1",
   *       modifiers: ["Control"]
   *     }
   *   ],
   *   editable: [
   *     {
   *       key: "F1"
   *     }
   *   ],
   *   onDown: () => { ui.notifications.info("Pressed!") },
   *   onUp: () => {},
   *   restricted: true,             // Restrict this Keybinding to gamemaster only?
   *   reservedModifiers: ["Alt"],  // On ALT, the notification is permanent instead of temporary
   *   precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL
   * });
   * ```
   */
  register(namespace, action, data) {
    if ( this.bindings ) throw new Error("You cannot register a Keybinding after the init hook");
    if ( !namespace || !action ) throw new Error("You must specify both the namespace and action portion of the Keybinding action");
    action = `${namespace}.${action}`;
    data.namespace = namespace;
    data.precedence = data.precedence ?? CONST.KEYBINDING_PRECEDENCE.NORMAL;
    data.order = this._registered++;
    data.uneditable = this.constructor._validateBindings(data.uneditable ?? []);
    data.editable = this.constructor._validateBindings(data.editable ?? []);
    data.repeat = data.repeat ?? false;
    data.reservedModifiers = this.constructor._validateModifiers(data.reservedModifiers ?? []);
    this.actions.set(action, data);
  }

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

  /**
   * Get the current Bindings of a given namespace's Keybinding Action
   *
   * @param {string} namespace   The namespace under which the setting is registered
   * @param {string} action      The keybind action to retrieve
   * @returns {KeybindingActionBinding[]}
   *
   * @example Retrieve the current Keybinding Action Bindings
   * ```js
   * game.keybindings.get("myModule", "showNotification");
   * ```
   */
  get(namespace, action) {
    if ( !namespace || !action ) throw new Error("You must specify both namespace and key portions of the keybind");
    action = `${namespace}.${action}`;
    const keybind = this.actions.get(action);
    if ( !keybind ) throw new Error("This is not a registered keybind action");
    return this.bindings.get(action) || [];
  }

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

  /**
   * Set the editable Bindings of a Keybinding Action for a certain namespace and Action
   *
   * @param {string} namespace                    The namespace under which the Keybinding is registered
   * @param {string} action                       The Keybinding action to set
   * @param {KeybindingActionBinding[]} bindings  The Bindings to assign to the Keybinding
   *
   * @example Update the current value of a keybinding
   * ```js
   * game.keybindings.set("myModule", "showNotification", [
   *     {
   *       key: "F2",
   *       modifiers: [ "CONTROL" ]
   *     }
   * ]);
   * ```
   */
  async set(namespace, action, bindings) {
    if ( !namespace || !action ) throw new Error("You must specify both namespace and action portions of the Keybind");
    action = `${namespace}.${action}`;
    const keybind = this.actions.get(action);
    if ( !keybind ) throw new Error("This is not a registered keybind");
    if ( keybind.restricted && !game.user.isGM ) throw new Error("Only a GM can edit this keybind");
    const mapping = game.settings.get("core", "keybindings");

    // Set to default if value is undefined and return
    if ( bindings === undefined ) {
      delete mapping[action];
      return game.settings.set("core", "keybindings", mapping);
    }
    bindings = this.constructor._validateBindings(bindings);

    // Verify no reserved Modifiers were set as Keys
    for ( let binding of bindings ) {
      if ( keybind.reservedModifiers.includes(binding.key) ) {
        throw new Error(game.i18n.format("KEYBINDINGS.ErrorReservedModifier", {key: binding.key}));
      }
    }

    // Save editable bindings to setting
    mapping[action] = bindings;
    await game.settings.set("core", "keybindings", mapping);
  }

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

  /**
   * Reset all client keybindings back to their default configuration.
   */
  async resetDefaults() {
    const setting = game.settings.settings.get("core.keybindings");
    return game.settings.set("core", "keybindings", setting.default);
  }

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

  /**
   * A helper method that, when given a value, ensures that the returned value is a standardized Binding array
   * @param {KeybindingActionBinding[]} values  An array of keybinding assignments to be validated
   * @returns {KeybindingActionBinding[]}       An array of keybinding assignments confirmed as valid
   * @private
   */
  static _validateBindings(values) {
    if ( !(values instanceof Array) ) throw new Error(game.i18n.localize("KEYBINDINGS.MustBeArray"));
    for ( let binding of values ) {
      if ( !binding.key ) throw new Error("Each KeybindingActionBinding must contain a valid key designation");
      if ( KeyboardManager.PROTECTED_KEYS.includes(binding.key) ) {
        throw new Error(game.i18n.format("KEYBINDINGS.ErrorProtectedKey", { key: binding.key }));
      }
      binding.modifiers = this._validateModifiers(binding.modifiers ?? []);
    }
    return values;
  }

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

  /**
   * Validate that assigned modifiers are allowed
   * @param {string[]} keys           An array of modifiers which may be valid
   * @returns {string[]}              An array of modifiers which are confirmed as valid
   * @private
   */
  static _validateModifiers(keys) {
    const modifiers = [];
    for ( let key of keys ) {
      if ( key in KeyboardManager.MODIFIER_KEYS ) key = KeyboardManager.MODIFIER_KEYS[key]; // backwards-compat
      if ( !Object.values(KeyboardManager.MODIFIER_KEYS).includes(key) ) {
        throw new Error(game.i18n.format("KEYBINDINGS.ErrorIllegalModifier", { key, allowed: modifiers.join(",") }));
      }
      modifiers.push(key);
    }
    return modifiers;
  }

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

  /**
   * Compares two Keybinding Actions based on their Order
   * @param {KeybindingAction} a   The first Keybinding Action
   * @param {KeybindingAction} b   the second Keybinding Action
   * @returns {number}
   * @internal
   */
  static _compareActions(a, b) {
    if (a.precedence === b.precedence) return a.order - b.order;
    return a.precedence - b.precedence;
  }

  /* ---------------------------------------- */
  /*  Core Keybinding Actions                 */
  /* ---------------------------------------- */

  /**
   * Register core keybindings.
   * @param {string} view           The active game view
   * @internal
   */
  _registerCoreKeybindings(view) {
    const {SHIFT, CONTROL, ALT} = KeyboardManager.MODIFIER_KEYS;

    // General Purpose - All Views
    game.keybindings.register("core", "dismiss", {
      name: "KEYBINDINGS.Dismiss",
      uneditable: [
        {key: "Escape"}
      ],
      onDown: ClientKeybindings._onDismiss,
      precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED
    });

    // Game View Only
    if ( view !== "game" ) return;
    game.keybindings.register("core", "cycleView", {
      name: "KEYBINDINGS.CycleView",
      editable: [
        {key: "Tab"}
      ],
      onDown: ClientKeybindings._onCycleView,
      reservedModifiers: [SHIFT],
      repeat: true
    });

    game.keybindings.register("core", "measuredRulerMovement", {
      name: "KEYBINDINGS.MoveAlongMeasuredRuler",
      editable: [
        {key: "Space"}
      ],
      onDown: ClientKeybindings._onMeasuredRulerMovement,
      precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY,
      reservedModifiers: [SHIFT, CONTROL]
    });
    game.keybindings.register("core", "pause", {
      name: "KEYBINDINGS.Pause",
      restricted: true,
      editable: [
        {key: "Space"}
      ],
      onDown: ClientKeybindings._onPause,
      precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED
    });
    game.keybindings.register("core", "delete", {
      name: "KEYBINDINGS.Delete",
      uneditable: [
        {key: "Delete"}
      ],
      editable: [
        {key: "Backspace"}
      ],
      onDown: ClientKeybindings._onDelete
    });
    game.keybindings.register("core", "highlight", {
      name: "KEYBINDINGS.Highlight",
      editable: [
        {key: "AltLeft"},
        {key: "AltRight"}
      ],
      onUp: ClientKeybindings._onHighlight,
      onDown: ClientKeybindings._onHighlight
    });
    game.keybindings.register("core", "selectAll", {
      name: "KEYBINDINGS.SelectAll",
      uneditable: [
        {key: "KeyA", modifiers: [CONTROL]}
      ],
      onDown: ClientKeybindings._onSelectAllObjects
    });
    game.keybindings.register("core", "undo", {
      name: "KEYBINDINGS.Undo",
      uneditable: [
        {key: "KeyZ", modifiers: [CONTROL]}
      ],
      onDown: ClientKeybindings._onUndo
    });
    game.keybindings.register("core", "copy", {
      name: "KEYBINDINGS.Copy",
      uneditable: [
        {key: "KeyC", modifiers: [CONTROL]}
      ],
      onDown: ClientKeybindings._onCopy
    });
    game.keybindings.register("core", "paste", {
      name: "KEYBINDINGS.Paste",
      uneditable: [
        {key: "KeyV", modifiers: [CONTROL]}
      ],
      onDown: ClientKeybindings._onPaste,
      reservedModifiers: [ALT, SHIFT]
    });
    game.keybindings.register("core", "sendToBack", {
      name: "KEYBINDINGS.SendToBack",
      editable: [
        {key: "BracketLeft"}
      ],
      onDown: ClientKeybindings.#onSendToBack
    });
    game.keybindings.register("core", "bringToFront", {
      name: "KEYBINDINGS.BringToFront",
      editable: [
        {key: "BracketRight"}
      ],
      onDown: ClientKeybindings.#onBringToFront
    });
    game.keybindings.register("core", "target", {
      name: "KEYBINDINGS.Target",
      editable: [
        {key: "KeyT"}
      ],
      onDown: ClientKeybindings._onTarget,
      reservedModifiers: [SHIFT]
    });
    game.keybindings.register("core", "characterSheet", {
      name: "KEYBINDINGS.ToggleCharacterSheet",
      editable: [
        {key: "KeyC"}
      ],
      onDown: ClientKeybindings._onToggleCharacterSheet,
      precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY
    });
    game.keybindings.register("core", "panUp", {
      name: "KEYBINDINGS.PanUp",
      uneditable: [
        {key: "ArrowUp"},
        {key: "Numpad8"}
      ],
      editable: [
        {key: "KeyW"}
      ],
      onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP]),
      onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.UP]),
      reservedModifiers: [CONTROL, SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panLeft", {
      name: "KEYBINDINGS.PanLeft",
      uneditable: [
        {key: "ArrowLeft"},
        {key: "Numpad4"}
      ],
      editable: [
        {key: "KeyA"}
      ],
      onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      reservedModifiers: [CONTROL, SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panDown", {
      name: "KEYBINDINGS.PanDown",
      uneditable: [
        {key: "ArrowDown"},
        {key: "Numpad2"}
      ],
      editable: [
        {key: "KeyS"}
      ],
      onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN]),
      onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN]),
      reservedModifiers: [CONTROL, SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panRight", {
      name: "KEYBINDINGS.PanRight",
      uneditable: [
        {key: "ArrowRight"},
        {key: "Numpad6"}
      ],
      editable: [
        {key: "KeyD"}
      ],
      onUp: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      onDown: context => this._onPan(context, [ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      reservedModifiers: [CONTROL, SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panUpLeft", {
      name: "KEYBINDINGS.PanUpLeft",
      uneditable: [
        {key: "Numpad7"}
      ],
      onUp: context => this._onPan(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      onDown: context => this._onPan(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      reservedModifiers: [CONTROL, SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panUpRight", {
      name: "KEYBINDINGS.PanUpRight",
      uneditable: [
        {key: "Numpad9"}
      ],
      onUp: context => this._onPan(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      onDown: context => this._onPan(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.UP, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      reservedModifiers: [CONTROL, SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panDownLeft", {
      name: "KEYBINDINGS.PanDownLeft",
      uneditable: [
        {key: "Numpad1"}
      ],
      onUp: context => this._onPan(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      onDown: context => this._onPan(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT]),
      reservedModifiers: [CONTROL, SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "panDownRight", {
      name: "KEYBINDINGS.PanDownRight",
      uneditable: [
        {key: "Numpad3"}
      ],
      onUp: context => this._onPan(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      onDown: context => this._onPan(context,
        [ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN, ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT]),
      reservedModifiers: [CONTROL, SHIFT],
      repeat: true
    });
    game.keybindings.register("core", "zoomIn", {
      name: "KEYBINDINGS.ZoomIn",
      uneditable: [
        {key: "NumpadAdd"}
      ],
      editable: [
        {key: "PageUp"}
      ],
      onDown: context => { ClientKeybindings._onZoom(context, ClientKeybindings.ZOOM_DIRECTIONS.IN); },
      repeat: true
    });
    game.keybindings.register("core", "zoomOut", {
      name: "KEYBINDINGS.ZoomOut",
      uneditable: [
        {key: "NumpadSubtract"}
      ],
      editable: [
        {key: "PageDown"}
      ],
      onDown: context => { ClientKeybindings._onZoom(context, ClientKeybindings.ZOOM_DIRECTIONS.OUT); },
      repeat: true
    });
    for ( const number of Array.fromRange(9, 1).concat([0]) ) {
      game.keybindings.register("core", `executeMacro${number}`, {
        name: game.i18n.format("KEYBINDINGS.ExecuteMacro", { number }),
        editable: [{key: `Digit${number}`}],
        onDown: context => ClientKeybindings._onMacroExecute(context, number),
        precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED
      });
    }
    for ( const page of Array.fromRange(5, 1) ) {
      game.keybindings.register("core", `swapMacroPage${page}`, {
        name: game.i18n.format("KEYBINDINGS.SwapMacroPage", { page }),
        editable: [{key: `Digit${page}`, modifiers: [ALT]}],
        onDown: context => ClientKeybindings._onMacroPageSwap(context, page),
        precedence: CONST.KEYBINDING_PRECEDENCE.DEFERRED
      });
    }
    game.keybindings.register("core", "pushToTalk", {
      name: "KEYBINDINGS.PTTKey",
      editable: [{key: "Backquote"}],
      onDown: game.webrtc._onPTTStart.bind(game.webrtc),
      onUp: game.webrtc._onPTTEnd.bind(game.webrtc),
      precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY,
      repeat: false
    });
    game.keybindings.register("core", "focusChat", {
      name: "KEYBINDINGS.FocusChat",
      editable: [{key: "KeyC", modifiers: [SHIFT]}],
      onDown: ClientKeybindings._onFocusChat,
      precedence: CONST.KEYBINDING_PRECEDENCE.PRIORITY,
      repeat: false
    });
  }

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

  /**
   * Handle Select all action
   * @param {KeyboardEventContext} context    The context data of the event
   * @private
   */
  static _onSelectAllObjects(context) {
    if ( !canvas.ready ) return false;
    canvas.activeLayer.controlAll();
    return true;
  }

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

  /**
   * Handle Cycle View actions
   * @param {KeyboardEventContext} context    The context data of the event
   * @private
   */
  static _onCycleView(context) {
    if ( !canvas.ready ) return false;

    // Attempt to cycle tokens, otherwise re-center the canvas
    if ( canvas.tokens.active ) {
      let cycled = canvas.tokens.cycleTokens(!context.isShift, false);
      if ( !cycled ) canvas.recenter();
    }
    return true;
  }

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

  /**
   * Handle Dismiss actions
   * @param {KeyboardEventContext} context    The context data of the event
   * @private
   */
  static async _onDismiss(context) {

    // Save fog of war if there are pending changes
    if ( canvas.ready ) canvas.fog.commit();

    // Case 1 - dismiss an open context menu
    if (ui.context && ui.context.menu.length) {
      await ui.context.close();
      return true;
    }

    // Case 2 - dismiss an open Tour
    if (Tour.tourInProgress) {
      Tour.activeTour.exit();
      return true;
    }

    // Case 3 - close open UI windows
    const closingApps = [];
    for ( const app of Object.values(ui.windows) ) {
      closingApps.push(app.close({closeKey: true}).then(() => !app.rendered));
    }
    for ( const app of foundry.applications.instances.values() ) {
      if ( app.hasFrame ) closingApps.push(app.close({closeKey: true}).then(() => !app.rendered));
    }
    const closedApp = (await Promise.all(closingApps)).some(c => c); // Confirm an application actually closed
    if ( closedApp ) return true;

    // Case 4 (GM) - release controlled objects (if not in a preview)
    if ( game.view !== "game" ) return;
    if (game.user.isGM && (canvas.activeLayer instanceof PlaceablesLayer) && canvas.activeLayer.controlled.length) {
      if ( !canvas.activeLayer.preview?.children.length ) canvas.activeLayer.releaseAll();
      return true;
    }

    // Case 5 - toggle the main menu
    ui.menu.toggle();
    // Save the fog immediately rather than waiting for the 3s debounced save as part of commitFog.
    if ( canvas.ready ) await canvas.fog.save();
    return true;
  }

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

  /**
   * Open Character sheet for current token or controlled actor
   * @param {KeyboardEventContext} context    The context data of the event
   * @private
   */
  static _onToggleCharacterSheet(context) {
    return game.toggleCharacterSheet();
  }

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

  /**
   * Handle action to target the currently hovered token.
   * @param {KeyboardEventContext} context    The context data of the event
   * @private
   */
  static _onTarget(context) {
    if ( !canvas.ready ) return false;
    const layer = canvas.activeLayer;
    if ( !(layer instanceof TokenLayer) ) return false;
    const hovered = layer.hover;
    if ( !hovered || hovered.document.isSecret ) return false;
    hovered.setTarget(!hovered.isTargeted, {releaseOthers: !context.isShift});
    return true;
  }

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

  /**
   * Handle action to send the currently controlled placeables to the back.
   * @param {KeyboardEventContext} context    The context data of the event
   */
  static #onSendToBack(context) {
    if ( !canvas.ready ) return false;
    return canvas.activeLayer?._sendToBackOrBringToFront(false) ?? false;
  }

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

  /**
   * Handle action to bring the currently controlled placeables to the front.
   * @param {KeyboardEventContext} context    The context data of the event
   */
  static #onBringToFront(context) {
    if ( !canvas.ready ) return false;
    return canvas.activeLayer?._sendToBackOrBringToFront(true) ?? false;
  }

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

  /**
   * Handle DELETE Keypress Events
   * @param {KeyboardEventContext} context    The context data of the event
   * @private
   */
  static _onDelete(context) {
    // Remove hotbar Macro
    if ( ui.hotbar._hover ) {
      game.user.assignHotbarMacro(null, ui.hotbar._hover);
      return true;
    }

    // Delete placeables from Canvas layer
    else if ( canvas.ready && ( canvas.activeLayer instanceof PlaceablesLayer ) ) {
      canvas.activeLayer._onDeleteKey(context.event);
      return true;
    }
  }

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

  /**
   * Handle keyboard movement once a small delay has elapsed to allow for multiple simultaneous key-presses.
   * @param {KeyboardEventContext} context        The context data of the event
   * @param {InteractionLayer} layer              The active InteractionLayer instance
   * @private
   */
  _handleMovement(context, layer) {
    if ( !this.moveKeys.size ) return;

    // Get controlled objects
    let objects = layer.placeables.filter(o => o.controlled);
    if ( objects.length === 0 ) return;

    // Get the directions of movement
    let directions = this.moveKeys;
    const grid = canvas.grid;
    const diagonals = (grid.type !== CONST.GRID_TYPES.SQUARE) || (grid.diagonals !== CONST.GRID_DIAGONALS.ILLEGAL);
    if ( !diagonals ) directions = new Set(Array.from(directions).slice(-1));

    // Define movement offsets and get moved directions
    let dx = 0;
    let dy = 0;
    if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT) ) dx -= 1;
    if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT) ) dx += 1;
    if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.UP) ) dy -= 1;
    if ( directions.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN) ) dy += 1;

    // Perform the shift or rotation
    layer.moveMany({dx, dy, rotate: context.isShift});
  }

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

  /**
   * Handle panning the canvas using CTRL + directional keys
   */
  _handleCanvasPan() {

    // Determine movement offsets
    let dx = 0;
    let dy = 0;
    if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT)) dx -= 1;
    if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.UP)) dy -= 1;
    if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT)) dx += 1;
    if (this.moveKeys.has(ClientKeybindings.MOVEMENT_DIRECTIONS.DOWN)) dy += 1;

    // Clear the pending set
    this.moveKeys.clear();

    // Pan by the grid size
    const s = canvas.dimensions.size;
    return canvas.animatePan({
      x: canvas.stage.pivot.x + (dx * s),
      y: canvas.stage.pivot.y + (dy * s),
      duration: 100
    });
  }

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

  /**
   * Handle Measured Ruler Movement Action
   * @param {KeyboardEventContext} context    The context data of the event
   * @private
   */
  static _onMeasuredRulerMovement(context) {
    if ( !canvas.ready ) return;
    const ruler = canvas.controls.ruler;
    if ( ruler.state !== Ruler.STATES.MEASURING ) return;
    ruler._onMoveKeyDown(context);
    return true;
  }

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

  /**
   * Handle Pause Action
   * @param {KeyboardEventContext} context    The context data of the event
   * @private
   */
  static _onPause(context) {
    game.togglePause(undefined, true);
    return true;
  }

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

  /**
   * Handle Highlight action
   * @param {KeyboardEventContext} context    The context data of the event
   * @private
   */
  static _onHighlight(context) {
    if ( !canvas.ready ) return false;
    canvas.highlightObjects(!context.up);
    return true;
  }

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

  /**
   * Handle Pan action
   * @param {KeyboardEventContext} context          The context data of the event
   * @param {string[]} movementDirections           The Directions being panned in
   * @private
   */
  _onPan(context, movementDirections) {

    // Case 1: Check for Tour
    if ( (Tour.tourInProgress) && (!context.repeat) && (!context.up) ) {
      Tour.onMovementAction(movementDirections);
      return true;
    }

    // Case 2: Check for Canvas
    if ( !canvas.ready ) return false;

    // Remove Keys on Up
    if ( context.up ) {
      for ( let d of movementDirections ) {
        this.moveKeys.delete(d);
      }
      return true;
    }

    // Keep track of when we last moved
    const now = Date.now();
    const delta = now - this._moveTime;

    // Track the movement set
    for ( let d of movementDirections ) {
      this.moveKeys.add(d);
    }

    // Handle canvas pan using CTRL
    if ( context.isControl ) {
      if ( ["KeyW", "KeyA", "KeyS", "KeyD"].includes(context.key) ) return false;
      this._handleCanvasPan();
      return true;
    }

    // Delay 50ms before shifting tokens in order to capture diagonal movements
    const layer = canvas.activeLayer;
    if ( (layer === canvas.tokens) || (layer === canvas.tiles) ) {
      if ( delta < 100 ) return true; // Throttle keyboard movement once per 100ms
      setTimeout(() => this._handleMovement(context, layer), 50);
    }
    this._moveTime = now;
    return true;
  }

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

  /**
   * Handle Macro executions
   * @param {KeyboardEventContext} context  The context data of the event
   * @param {number} number                 The numbered macro slot to execute
   * @private
   */
  static _onMacroExecute(context, number) {
    const slot = ui.hotbar.macros.find(m => m.key === number);
    if ( slot.macro ) {
      slot.macro.execute();
      return true;
    }
    return false;
  }

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

  /**
   * Handle Macro page swaps
   * @param {KeyboardEventContext} context    The context data of the event
   * @param {number} page                     The numbered macro page to activate
   * @private
   */
  static _onMacroPageSwap(context, page) {
    ui.hotbar.changePage(page);
    return true;
  }

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

  /**
   * Handle action to copy data to clipboard
   * @param {KeyboardEventContext} context    The context data of the event
   * @private
   */
  static _onCopy(context) {
    // Case 1 - attempt a copy operation on the PlaceablesLayer
    if (window.getSelection().toString() !== "") return false;
    if ( !canvas.ready ) return false;
    let layer = canvas.activeLayer;
    if ( layer instanceof PlaceablesLayer ) layer.copyObjects();
    return true;
  }

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

  /**
   * Handle Paste action
   * @param {KeyboardEventContext} context    The context data of the event
   * @private
   */
  static _onPaste(context ) {
    if ( !canvas.ready ) return false;
    let layer = canvas.activeLayer;
    if ( (layer instanceof PlaceablesLayer) && layer._copy.length ) {
      const pos = canvas.mousePosition;
      layer.pasteObjects(pos, {hidden: context.isAlt, snap: !context.isShift});
      return true;
    }
  }

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

  /**
   * Handle Undo action
   * @param {KeyboardEventContext} context    The context data of the event
   * @private
   */
  static _onUndo(context) {
    if ( !canvas.ready ) return false;

    // Undo history for a PlaceablesLayer
    const layer = canvas.activeLayer;
    if ( !(layer instanceof PlaceablesLayer) ) return false;
    layer.undoHistory();
    return true;
  }

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

  /**
   * Handle presses to keyboard zoom keys
   * @param {KeyboardEventContext} context                    The context data of the event
   * @param {ClientKeybindings.ZOOM_DIRECTIONS} zoomDirection The direction to zoom
   * @private
   */
  static _onZoom(context, zoomDirection ) {
    if ( !canvas.ready ) return false;
    const delta = zoomDirection === ClientKeybindings.ZOOM_DIRECTIONS.IN ? 1.05 : 0.95;
    canvas.animatePan({scale: delta * canvas.stage.scale.x, duration: 100});
    return true;
  }

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

  /**
   * Bring the chat window into view and focus the input
   * @param {KeyboardEventContext} context    The context data of the event
   * @returns {boolean}
   * @private
   */
  static _onFocusChat(context) {
    const sidebar = ui.sidebar._element[0];
    ui.sidebar.activateTab(ui.chat.tabName);

    // If the sidebar is collapsed and the chat popover is not visible, open it
    if ( sidebar.classList.contains("collapsed") && !ui.chat._popout ) {
      const popout = ui.chat.createPopout();
      popout._render(true).then(() => {
        popout.element.find("#chat-message").focus();
      });
    }
    else {
      ui.chat.element.find("#chat-message").focus();
    }
    return true;
  }
}

/**
 * A set of helpers and management functions for dealing with user input from keyboard events.
 * {@link https://keycode.info/}
 */
class KeyboardManager {
  constructor() {
    this._reset();
  }

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

  /**
   * Begin listening to keyboard events.
   * @internal
   */
  _activateListeners() {
    window.addEventListener("keydown", event => this._handleKeyboardEvent(event, false));
    window.addEventListener("keyup", event => this._handleKeyboardEvent(event, true));
    window.addEventListener("visibilitychange", this._reset.bind(this));
    window.addEventListener("compositionend", this._onCompositionEnd.bind(this));
    window.addEventListener("focusin", this._onFocusIn.bind(this));
  }

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

  /**
   * The set of key codes which are currently depressed (down)
   * @type {Set<string>}
   */
  downKeys = new Set();

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

  /**
   * The set of movement keys which were recently pressed
   * @type {Set<string>}
   */
  moveKeys = new Set();

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

  /**
   * Allowed modifier keys
   * @enum {string}
   */
  static MODIFIER_KEYS = {
    CONTROL: "Control",
    SHIFT: "Shift",
    ALT: "Alt"
  };

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

  /**
   * Track which KeyboardEvent#code presses associate with each modifier
   * @enum {string[]}
   */
  static MODIFIER_CODES = {
    [this.MODIFIER_KEYS.ALT]: ["AltLeft", "AltRight"],
    [this.MODIFIER_KEYS.CONTROL]: ["ControlLeft", "ControlRight", "MetaLeft", "MetaRight", "Meta", "OsLeft", "OsRight"],
    [this.MODIFIER_KEYS.SHIFT]: ["ShiftLeft", "ShiftRight"]
  };

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

  /**
   * Key codes which are "protected" and should not be used because they are reserved for browser-level actions.
   * @type {string[]}
   */
  static PROTECTED_KEYS = ["F5", "F11", "F12", "PrintScreen", "ScrollLock", "NumLock", "CapsLock"];

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

  /**
   * The OS-specific string display for what their Command key is
   * @type {string}
   */
  static CONTROL_KEY_STRING = navigator.appVersion.includes("Mac") ? "⌘" : "Control";

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

  /**
   * A special mapping of how special KeyboardEvent#code values should map to displayed strings or symbols.
   * Values in this configuration object override any other display formatting rules which may be applied.
   * @type {Record<string, string>}
   */
  static KEYCODE_DISPLAY_MAPPING = (() => {
    const isMac = navigator.appVersion.includes("Mac");
    return {
      ArrowLeft: isMac ? "←" : "🡸",
      ArrowRight: isMac ? "→" : "🡺",
      ArrowUp: isMac ? "↑" : "🡹",
      ArrowDown: isMac ? "↓" : "🡻",
      Backquote: "`",
      Backslash: "\\",
      BracketLeft: "[",
      BracketRight: "]",
      Comma: ",",
      Control: this.CONTROL_KEY_STRING,
      Equal: "=",
      Meta: isMac ? "⌘" : "⊞",
      MetaLeft: isMac ? "⌘" : "⊞",
      MetaRight: isMac ? "⌘" : "⊞",
      OsLeft: isMac ? "⌘" : "⊞",
      OsRight: isMac ? "⌘" : "⊞",
      Minus: "-",
      NumpadAdd: "Numpad+",
      NumpadSubtract: "Numpad-",
      Period: ".",
      Quote: "'",
      Semicolon: ";",
      Slash: "/"
    };
  })();

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

  /**
   * Test whether an HTMLElement currently has focus.
   * If so we normally don't want to process keybinding actions.
   * @type {boolean}
   */
  get hasFocus() {
    return document.querySelector(":focus") instanceof HTMLElement;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Emulates a key being pressed, triggering the Keyboard event workflow.
   * @param {boolean} up                        If True, emulates the `keyup` Event. Else, the `keydown` event
   * @param {string} code                       The KeyboardEvent#code which is being pressed
   * @param {object} [options]                  Additional options to configure behavior.
   * @param {boolean} [options.altKey=false]    Emulate the ALT modifier as pressed
   * @param {boolean} [options.ctrlKey=false]   Emulate the CONTROL modifier as pressed
   * @param {boolean} [options.shiftKey=false]  Emulate the SHIFT modifier as pressed
   * @param {boolean} [options.repeat=false]    Emulate this as a repeat event
   * @param {boolean} [options.force=false]     Force the event to be handled.
   * @returns {KeyboardEventContext}
   */
  static emulateKeypress(up, code, {altKey=false, ctrlKey=false, shiftKey=false, repeat=false, force=false}={}) {
    const event = new KeyboardEvent(`key${up ? "up" : "down"}`, {code, altKey, ctrlKey, shiftKey, repeat});
    const context = this.getKeyboardEventContext(event, up);
    game.keyboard._processKeyboardContext(context, {force});
    game.keyboard.downKeys.delete(context.key);
    return context;
  }

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

  /**
   * Format a KeyboardEvent#code into a displayed string.
   * @param {string} code       The input code
   * @returns {string}          The displayed string for this code
   */
  static getKeycodeDisplayString(code) {
    if ( code in this.KEYCODE_DISPLAY_MAPPING ) return this.KEYCODE_DISPLAY_MAPPING[code];
    if ( code.startsWith("Digit") ) return code.replace("Digit", "");
    if ( code.startsWith("Key") ) return code.replace("Key", "");
    return code;
  }

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

  /**
   * Get a standardized keyboard context for a given event.
   * Every individual keypress is uniquely identified using the KeyboardEvent#code property.
   * A list of possible key codes is documented here: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values
   *
   * @param {KeyboardEvent} event   The originating keypress event
   * @param {boolean} up            A flag for whether the key is down or up
   * @return {KeyboardEventContext} The standardized context of the event
   */
  static getKeyboardEventContext(event, up=false) {
    let context = {
      event: event,
      key: event.code,
      isShift: event.shiftKey,
      isControl: event.ctrlKey || event.metaKey,
      isAlt: event.altKey,
      hasModifier: event.shiftKey || event.ctrlKey || event.metaKey || event.altKey,
      modifiers: [],
      up: up,
      repeat: event.repeat
    };
    if ( context.isShift ) context.modifiers.push(this.MODIFIER_KEYS.SHIFT);
    if ( context.isControl ) context.modifiers.push(this.MODIFIER_KEYS.CONTROL);
    if ( context.isAlt ) context.modifiers.push(this.MODIFIER_KEYS.ALT);
    return context;
  }

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

  /**
   * Report whether a modifier in KeyboardManager.MODIFIER_KEYS is currently actively depressed.
   * @param {string} modifier     A modifier in MODIFIER_KEYS
   * @returns {boolean}           Is this modifier key currently down (active)?
   */
  isModifierActive(modifier) {
    return this.constructor.MODIFIER_CODES[modifier].some(k => this.downKeys.has(k));
  }

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

  /**
   * Report whether a core action key is currently actively depressed.
   * @param {string} action       The core action to verify (ex: "target")
   * @returns {boolean}           Is this core action key currently down (active)?
   */
  isCoreActionKeyActive(action) {
    const binds = game.keybindings.get("core", action);
    return !!binds?.some(k => this.downKeys.has(k.key));
  }

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

  /**
   * Converts a Keyboard Context event into a string representation, such as "C" or "Control+C"
   * @param {KeyboardEventContext} context  The standardized context of the event
   * @param {boolean} includeModifiers      If True, includes modifiers in the string representation
   * @return {string}
   * @private
   */
  static _getContextDisplayString(context, includeModifiers = true) {
    const parts = [this.getKeycodeDisplayString(context.key)];
    if ( includeModifiers && context.hasModifier ) {
      if ( context.isShift && context.event.key !== "Shift" ) parts.unshift(this.MODIFIER_KEYS.SHIFT);
      if ( context.isControl && context.event.key !== "Control" ) parts.unshift(this.MODIFIER_KEYS.CONTROL);
      if ( context.isAlt && context.event.key !== "Alt" ) parts.unshift(this.MODIFIER_KEYS.ALT);
    }
    return parts.join("+");
  }

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

  /**
   * Given a standardized pressed key, find all matching registered Keybind Actions.
   * @param {KeyboardEventContext} context  A standardized keyboard event context
   * @return {KeybindingAction[]}           The matched Keybind Actions. May be empty.
   * @internal
   */
  static _getMatchingActions(context) {
    let possibleMatches = game.keybindings.activeKeys.get(context.key) ?? [];
    if ( CONFIG.debug.keybindings ) console.dir(possibleMatches);
    return possibleMatches.filter(action => KeyboardManager._testContext(action, context));
  }

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

  /**
   * Test whether a keypress context matches the registration for a keybinding action
   * @param {KeybindingAction} action             The keybinding action
   * @param {KeyboardEventContext} context        The keyboard event context
   * @returns {boolean}                           Does the context match the action requirements?
   * @private
   */
  static _testContext(action, context) {
    if ( context.repeat && !action.repeat ) return false;
    if ( action.restricted && !game.user.isGM ) return false;

    // If the context includes no modifiers, we match if the binding has none
    if ( !context.hasModifier ) return action.requiredModifiers.length === 0;

    // Test that modifiers match expectation
    const modifiers = this.MODIFIER_KEYS;
    const activeModifiers = {
      [modifiers.CONTROL]: context.isControl,
      [modifiers.SHIFT]: context.isShift,
      [modifiers.ALT]: context.isAlt
    };
    for (let [k, v] of Object.entries(activeModifiers)) {

      // Ignore exact matches to a modifier key
      if ( this.MODIFIER_CODES[k].includes(context.key) ) continue;

      // Verify that required modifiers are present
      if ( action.requiredModifiers.includes(k) ) {
        if ( !v ) return false;
      }

      // No unsupported modifiers can be present for a "down" event
      else if ( !context.up && !action.optionalModifiers.includes(k) && v ) return false;
    }
    return true;
  }

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

  /**
   * Given a registered Keybinding Action, executes the action with a given event and context
   *
   * @param {KeybindingAction} keybind         The registered Keybinding action to execute
   * @param {KeyboardEventContext} context     The gathered context of the event
   * @return {boolean}                         Returns true if the keybind was consumed
   * @private
   */
  static _executeKeybind(keybind, context) {
    if ( CONFIG.debug.keybindings ) console.log("Executing " + game.i18n.localize(keybind.name));
    context.action = keybind.action;
    let consumed = false;
    if ( context.up && keybind.onUp ) consumed = keybind.onUp(context);
    else if ( !context.up && keybind.onDown ) consumed = keybind.onDown(context);
    return consumed;
  }

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

  /**
   * Processes a keyboard event context, checking it against registered keybinding actions
   * @param {KeyboardEventContext} context   The keyboard event context
   * @param {object} [options]               Additional options to configure behavior.
   * @param {boolean} [options.force=false]  Force the event to be handled.
   * @protected
   */
  _processKeyboardContext(context, {force=false}={}) {

    // Track the current set of pressed keys
    if ( context.up ) this.downKeys.delete(context.key);
    else this.downKeys.add(context.key);

    // If an input field has focus, don't process Keybinding Actions
    if ( this.hasFocus && !force ) return;

    // Open debugging group
    if ( CONFIG.debug.keybindings ) {
      console.group(`[${context.up ? 'UP' : 'DOWN'}] Checking for keybinds that respond to ${context.modifiers}+${context.key}`);
      console.dir(context);
    }

    // Check against registered Keybindings
    const actions = KeyboardManager._getMatchingActions(context);
    if (actions.length === 0) {
      if ( CONFIG.debug.keybindings ) {
        console.log("No matching keybinds");
        console.groupEnd();
      }
      return;
    }

    // Execute matching Keybinding Actions to see if any consume the event
    let handled;
    for ( const action of actions ) {
      handled = KeyboardManager._executeKeybind(action, context);
      if ( handled ) break;
    }

    // Cancel event since we handled it
    if ( handled && context.event ) {
      if ( CONFIG.debug.keybindings ) console.log("Event was consumed");
      context.event?.preventDefault();
      context.event?.stopPropagation();
    }
    if ( CONFIG.debug.keybindings ) console.groupEnd();
  }

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

  /**
   * Reset tracking for which keys are in the down and released states
   * @private
   */
  _reset() {
    this.downKeys = new Set();
    this.moveKeys = new Set();
  }

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

  /**
   * Emulate a key-up event for any currently down keys. When emulating, we go backwards such that combinations such as
   * "CONTROL + S" emulate the "S" first in order to capture modifiers.
   * @param {object} [options]              Options to configure behavior.
   * @param {boolean} [options.force=true]  Force the keyup events to be handled.
   */
  releaseKeys({force=true}={}) {
    const reverseKeys = Array.from(this.downKeys).reverse();
    for ( const key of reverseKeys ) {
      this.constructor.emulateKeypress(true, key, {
        force,
        ctrlKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.CONTROL),
        shiftKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.SHIFT),
        altKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.ALT)
      });
    }
  }

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

  /**
   * Handle a key press into the down position
   * @param {KeyboardEvent} event   The originating keyboard event
   * @param {boolean} up            A flag for whether the key is down or up
   * @private
   */
  _handleKeyboardEvent(event, up) {
    if ( event.isComposing ) return; // Ignore IME composition
    if ( !event.key && !event.code ) return; // Some browsers fire keyup and keydown events when autocompleting values.
    let context = KeyboardManager.getKeyboardEventContext(event, up);
    this._processKeyboardContext(context);
  }

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

  /**
   * Input events do not fire with isComposing = false at the end of a composition event in Chrome
   * See: https://github.com/w3c/uievents/issues/202
   * @param {CompositionEvent} event
   */
  _onCompositionEnd(event) {
    return this._handleKeyboardEvent(event, false);
  }

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

  /**
   * Release any down keys when focusing a form element.
   * @param {FocusEvent} event  The focus event.
   * @protected
   */
  _onFocusIn(event) {
    const formElements = [
      HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLOptionElement, HTMLButtonElement
    ];
    if ( event.target.isContentEditable || formElements.some(cls => event.target instanceof cls) ) this.releaseKeys();
  }
}

/**
 * Management class for Mouse events
 */
class MouseManager {
  constructor() {
    this._wheelTime = 0;
  }

  /**
   * Specify a rate limit for mouse wheel to gate repeated scrolling.
   * This is especially important for continuous scrolling mice which emit hundreds of events per second.
   * This designates a minimum number of milliseconds which must pass before another wheel event is handled
   * @type {number}
   */
  static MOUSE_WHEEL_RATE_LIMIT = 50;

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

  /**
   * Begin listening to mouse events.
   * @internal
   */
  _activateListeners() {
    window.addEventListener("wheel", this._onWheel.bind(this), {passive: false});
  }

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

  /**
   * Master mouse-wheel event handler
   * @param {WheelEvent} event    The mouse wheel event
   * @private
   */
  _onWheel(event) {

    // Prevent zooming the entire browser window
    if ( event.ctrlKey ) event.preventDefault();

    // Interpret shift+scroll as vertical scroll
    let dy = event.delta = event.deltaY;
    if ( event.shiftKey && (dy === 0) ) {
      dy = event.delta = event.deltaX;
    }
    if ( dy === 0 ) return;

    // Take no actions if the canvas is not hovered
    if ( !canvas.ready ) return;
    const hover = document.elementFromPoint(event.clientX, event.clientY);
    if ( !hover || (hover.id !== "board") ) return;
    event.preventDefault();

    // Identify scroll modifiers
    const isCtrl = event.ctrlKey || event.metaKey;
    const isShift = event.shiftKey;
    const layer = canvas.activeLayer;

    // Case 1 - rotate placeable objects
    if ( layer?.options?.rotatableObjects && (isCtrl || isShift) ) {
      const hasTarget = layer.options?.controllableObjects ? layer.controlled.length : !!layer.hover;
      if ( hasTarget ) {
        const t = Date.now();
        if ( (t - this._wheelTime) < this.constructor.MOUSE_WHEEL_RATE_LIMIT ) return;
        this._wheelTime = t;
        return layer._onMouseWheel(event);
      }
    }

    // Case 2 - zoom the canvas
    canvas._onMouseWheel(event);
  }
}

/**
 * Responsible for managing the New User Experience workflows.
 */
class NewUserExperience {
  constructor() {
    Hooks.on("renderChatMessage", this._activateListeners.bind(this));
  }

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

  /**
   * Initialize the new user experience.
   * Currently, this generates some chat messages with hints for getting started if we detect this is a new world.
   */
  initialize() {
    // If there are no documents, we can reasonably assume this is a new World.
    const isNewWorld = !(game.actors.size + game.scenes.size + game.items.size + game.journal.size);
    if ( !isNewWorld ) return;
    this._createInitialChatMessages();
    // noinspection JSIgnoredPromiseFromCall
    this._showNewWorldTour();
  }

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

  /**
   * Show chat tips for first launch.
   * @private
   */
  _createInitialChatMessages() {
    if ( game.settings.get("core", "nue.shownTips") ) return;

    // Get GM's
    const gms = ChatMessage.getWhisperRecipients("GM");

    // Build Chat Messages
    const content = [`
      <h3 class="nue">${game.i18n.localize("NUE.FirstLaunchHeader")}</h3>
      <p class="nue">${game.i18n.localize("NUE.FirstLaunchBody")}</p>
      <p class="nue">${game.i18n.localize("NUE.FirstLaunchKB")}</p>
      <footer class="nue">${game.i18n.localize("NUE.FirstLaunchHint")}</footer>
    `, `
      <h3 class="nue">${game.i18n.localize("NUE.FirstLaunchInvite")}</h3>
      <p class="nue">${game.i18n.localize("NUE.FirstLaunchInviteBody")}</p>
      <p class="nue">${game.i18n.localize("NUE.FirstLaunchTroubleshooting")}</p>
      <footer class="nue">${game.i18n.localize("NUE.FirstLaunchHint")}</footer>
    `];
    const chatData = content.map(c => {
      return {
        whisper: gms,
        speaker: {alias: game.i18n.localize("Foundry Virtual Tabletop")},
        flags: {core: {nue: true, canPopout: true}},
        content: c
      };
    });
    ChatMessage.implementation.createDocuments(chatData);

    // Store flag indicating this was shown
    game.settings.set("core", "nue.shownTips", true);
  }

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

  /**
   * Create a default scene for the new world.
   * @private
   */
  async _createDefaultScene() {
    if ( !game.user.isGM ) return;
    const filePath = foundry.utils.getRoute("/nue/defaultscene/scene.json");
    const response = await foundry.utils.fetchWithTimeout(filePath, {method: "GET"});
    const json = await response.json();
    const scene = await Scene.create(json);
    await scene.activate();
    canvas.animatePan({scale: 0.7, duration: 100});
  }

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

  /**
   * Automatically show uncompleted Tours related to new worlds.
   * @private
   */
  async _showNewWorldTour() {
    const tour = game.tours.get("core.welcome");
    if ( tour?.status === Tour.STATUS.UNSTARTED ) {
      await this._createDefaultScene();
      tour.start();
    }
  }

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

  /**
   * Add event listeners to the chat card links.
   * @param {ChatMessage} msg  The ChatMessage being rendered.
   * @param {jQuery} html      The HTML content of the message.
   * @private
   */
  _activateListeners(msg, html) {
    if ( !msg.getFlag("core", "nue") ) return;
    html.find(".nue-tab").click(this._onTabLink.bind(this));
    html.find(".nue-action").click(this._onActionLink.bind(this));
  }

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

  /**
   * Perform some special action triggered by clicking on a link in a NUE chat card.
   * @param {TriggeredEvent} event  The click event.
   * @private
   */
  _onActionLink(event) {
    event.preventDefault();
    const action = event.currentTarget.dataset.action;
    switch ( action ) {
      case "invite": return new InvitationLinks().render(true);
    }
  }

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

  /**
   * Switch to the appropriate tab when a user clicks on a link in the chat message.
   * @param {TriggeredEvent} event  The click event.
   * @private
   */
  _onTabLink(event) {
    event.preventDefault();
    const tab = event.currentTarget.dataset.tab;
    ui.sidebar.activateTab(tab);
  }
}

/**
 * @typedef {Object} PackageCompatibilityBadge
 * @property {string} type        A type in "safe", "unsafe", "warning", "neutral" applied as a CSS class
 * @property {string} tooltip     A tooltip string displayed when hovering over the badge
 * @property {string} [label]     An optional text label displayed in the badge
 * @property {string} [icon]      An optional icon displayed in the badge
 */


/**
 * A client-side mixin used for all Package types.
 * @param {typeof BasePackage} BasePackage    The parent BasePackage class being mixed
 * @returns {typeof ClientPackage}            A BasePackage subclass mixed with ClientPackage features
 * @category - Mixins
 */
function ClientPackageMixin(BasePackage) {
  class ClientPackage extends BasePackage {

    /**
     * Is this package marked as a favorite?
     * This boolean is currently only populated as true in the /setup view of the software.
     * @type {boolean}
     */
    favorite = false;

    /**
     * Associate package availability with certain badge for client-side display.
     * @returns {PackageCompatibilityBadge|null}
     */
    getVersionBadge() {
      return this.constructor.getVersionBadge(this.availability, this);
    }

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

    /**
     * Determine a version badge for the provided compatibility data.
     * @param {number} availability                The availability level.
     * @param {Partial<PackageManifestData>} data  The compatibility data.
     * @param {object} [options]
     * @param {Collection<string, Module>} [options.modules]  A specific collection of modules to test availability
     *                                                        against. Tests against the currently installed modules by
     *                                                        default.
     * @param {Collection<string, System>} [options.systems]  A specific collection of systems to test availability
     *                                                        against. Tests against the currently installed systems by
     *                                                        default.
     * @returns {PackageCompatibilityBadge|null}
     */
    static getVersionBadge(availability, data, { modules, systems }={}) {
      modules ??= game.modules;
      systems ??= game.systems;
      const codes = CONST.PACKAGE_AVAILABILITY_CODES;
      const { compatibility, version, relationships } = data;
      switch ( availability ) {

        // Unsafe
        case codes.UNKNOWN:
        case codes.REQUIRES_CORE_DOWNGRADE:
        case codes.REQUIRES_CORE_UPGRADE_STABLE:
        case codes.REQUIRES_CORE_UPGRADE_UNSTABLE:
          const labels = {
            [codes.UNKNOWN]: "SETUP.CompatibilityUnknown",
            [codes.REQUIRES_CORE_DOWNGRADE]: "SETUP.RequireCoreDowngrade",
            [codes.REQUIRES_CORE_UPGRADE_STABLE]: "SETUP.RequireCoreUpgrade",
            [codes.REQUIRES_CORE_UPGRADE_UNSTABLE]: "SETUP.RequireCoreUnstable"
          };
          return {
            type: "error",
            tooltip: game.i18n.localize(labels[availability]),
            label: version,
            icon: "fa fa-file-slash"
          };

        case codes.MISSING_SYSTEM:
          return {
            type: "error",
            tooltip: game.i18n.format("SETUP.RequireDep", { dependencies: data.system }),
            label: version,
            icon: "fa fa-file-slash"
          };

        case codes.MISSING_DEPENDENCY:
        case codes.REQUIRES_DEPENDENCY_UPDATE:
          return {
            type: "error",
            label: version,
            icon: "fa fa-file-slash",
            tooltip: this._formatBadDependenciesTooltip(availability, data, relationships.requires, {
              modules, systems
            })
          };

        // Warning
        case codes.UNVERIFIED_GENERATION:
          return {
            type: "warning",
            tooltip: game.i18n.format("SETUP.CompatibilityRiskWithVersion", { version: compatibility.verified }),
            label: version,
            icon: "fas fa-exclamation-triangle"
          };

        case codes.UNVERIFIED_SYSTEM:
          return {
            type: "warning",
            label: version,
            icon: "fas fa-exclamation-triangle",
            tooltip: this._formatIncompatibleSystemsTooltip(data, relationships.systems, { systems })
          };

        // Neutral
        case codes.UNVERIFIED_BUILD:
          return {
            type: "neutral",
            tooltip: game.i18n.format("SETUP.CompatibilityRiskWithVersion", { version: compatibility.verified }),
            label: version,
            icon: "fas fa-code-branch"
          };

        // Safe
        case codes.VERIFIED:
          return {
            type: "success",
            tooltip: game.i18n.localize("SETUP.Verified"),
            label: version,
            icon: "fas fa-code-branch"
          };
      }
      return null;
    }

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

    /**
     * List missing dependencies and format them for display.
     * @param {number} availability                The availability value.
     * @param {Partial<PackageManifestData>} data  The compatibility data.
     * @param {Iterable<RelatedPackage>} deps      The dependencies to format.
     * @param {object} [options]
     * @param {Collection<string, Module>} [options.modules]  A specific collection of modules to test availability
     *                                                        against. Tests against the currently installed modules by
     *                                                        default.
     * @param {Collection<string, System>} [options.systems]  A specific collection of systems to test availability
     *                                                        against. Tests against the currently installed systems by
     *                                                        default.
     * @returns {string}
     * @protected
     */
    static _formatBadDependenciesTooltip(availability, data, deps, { modules, systems }={}) {
      modules ??= game.modules;
      systems ??= game.systems;
      const codes = CONST.PACKAGE_AVAILABILITY_CODES;
      const checked = new Set();
      const bad = [];
      for ( const dep of deps ) {
        if ( (dep.type !== "module") || checked.has(dep.id) ) continue;
        if ( !modules.has(dep.id) ) bad.push(dep.id);
        else if ( availability === codes.REQUIRES_DEPENDENCY_UPDATE ) {
          const module = modules.get(dep.id);
          if ( module.availability !== codes.VERIFIED ) bad.push(dep.id);
        }
        checked.add(dep.id);
      }
      const label = availability === codes.MISSING_DEPENDENCY ? "SETUP.RequireDep" : "SETUP.IncompatibleDep";
      const formatter = game.i18n.getListFormatter({ style: "short", type: "unit" });
      return game.i18n.format(label, { dependencies: formatter.format(bad) });
    }

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

    /**
     * List any installed systems that are incompatible with this module's systems relationship, and format them for
     * display.
     * @param {Partial<PackageManifestData>} data             The compatibility data.
     * @param {Iterable<RelatedPackage>} relationships        The system relationships.
     * @param {object} [options]
     * @param {Collection<string, System>} [options.systems]  A specific collection of systems to test against. Tests
     *                                                        against the currently installed systems by default.
     * @returns {string}
     * @protected
     */
    static _formatIncompatibleSystemsTooltip(data, relationships, { systems }={}) {
      systems ??= game.systems;
      const incompatible = [];
      for ( const { id, compatibility } of relationships ) {
        const system = systems.get(id);
        if ( !system ) continue;
        if ( !this.testDependencyCompatibility(compatibility, system) || system.unavailable ) incompatible.push(id);
      }
      const label = incompatible.length ? "SETUP.IncompatibleSystems" : "SETUP.NoSupportedSystem";
      const formatter = game.i18n.getListFormatter({ style: "short", type: "unit" });
      return game.i18n.format(label, { systems: formatter.format(incompatible) });
    }

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

    /**
     * When a package has been installed, add it to the local game data.
     */
    install() {
      const collection = this.constructor.collection;
      game.data[collection].push(this.toObject());
      game[collection].set(this.id, this);
    }

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

    /**
     * When a package has been uninstalled, remove it from the local game data.
     */
    uninstall() {
      this.constructor.uninstall(this.id);
    }

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

    /**
     * Remove a package from the local game data when it has been uninstalled.
     * @param {string} id  The package ID.
     */
    static uninstall(id) {
      game.data[this.collection].findSplice(p => p.id === id);
      game[this.collection].delete(id);
    }

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

    /**
     * Retrieve the latest Package manifest from a provided remote location.
     * @param {string} manifest                 A remote manifest URL to load
     * @param {object} options                  Additional options which affect package construction
     * @param {boolean} [options.strict=true]   Whether to construct the remote package strictly
     * @returns {Promise<ClientPackage|null>}   A Promise which resolves to a constructed ServerPackage instance
     * @throws                                  An error if the retrieved manifest data is invalid
     */
    static async fromRemoteManifest(manifest, {strict=false}={}) {
      try {
        const data = await Setup.post({action: "getPackageFromRemoteManifest", type: this.type, manifest});
        return new this(data, {installed: false, strict: strict});
      }
      catch(e) {
        return null;
      }
    }
  }
  return ClientPackage;
}

/**
 * @extends foundry.packages.BaseModule
 * @mixes ClientPackageMixin
 * @category - Packages
 */
class Module extends ClientPackageMixin(foundry.packages.BaseModule) {
  constructor(data, options = {}) {
    const {active} = data;
    super(data, options);

    /**
     * Is this package currently active?
     * @type {boolean}
     */
    Object.defineProperty(this, "active", {value: active, writable: false});
  }
}

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

/**
 * @extends foundry.packages.BaseSystem
 * @mixes ClientPackageMixin
 * @category - Packages
 */
class System extends ClientPackageMixin(foundry.packages.BaseSystem) {
  constructor(data, options={}) {
    options.strictDataCleaning = data.strictDataCleaning;
    super(data, options);
  }

  /** @inheritDoc */
  _configure(options) {
    super._configure(options);
    this.strictDataCleaning = !!options.strictDataCleaning;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  get template() {
    foundry.utils.logCompatibilityWarning("System#template is deprecated in favor of System#documentTypes",
      {since: 12, until: 14});
    return game.model;
  }
}

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

/**
 * @extends foundry.packages.BaseWorld
 * @mixes ClientPackageMixin
 * @category - Packages
 */
class World extends ClientPackageMixin(foundry.packages.BaseWorld) {

  /** @inheritDoc */
  static getVersionBadge(availability, data, { modules, systems }={}) {
    modules ??= game.modules;
    systems ??= game.systems;
    const badge = super.getVersionBadge(availability, data, { modules, systems });
    if ( !badge ) return badge;
    const codes = CONST.PACKAGE_AVAILABILITY_CODES;
    if ( availability === codes.VERIFIED ) {
      const system = systems.get(data.system);
      if ( system.availability !== codes.VERIFIED ) badge.type = "neutral";
    }
    if ( !data.manifest ) badge.label = "";
    return badge;
  }

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

  /**
   * Provide data for a system badge displayed for the world which reflects the system ID and its availability
   * @param {System} [system]  A specific system to use, otherwise use the installed system.
   * @returns {PackageCompatibilityBadge|null}
   */
  getSystemBadge(system) {
    system ??= game.systems.get(this.system);
    if ( !system ) return {
      type: "error",
      tooltip: game.i18n.format("SETUP.RequireSystem", { system: this.system }),
      label: this.system,
      icon: "fa fa-file-slash"
    };
    const badge = system.getVersionBadge();
    if ( badge.type === "safe" ) {
      badge.type = "neutral";
      badge.icon = null;
    }
    badge.tooltip = `<p>${system.title}</p><p>${badge.tooltip}</p>`;
    badge.label = system.id;
    return badge;
  }

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

  /** @inheritdoc */
  static _formatBadDependenciesTooltip(availability, data, deps) {
    const system = game.systems.get(data.system);
    if ( system ) deps ??= [...data.relationships.requires.values(), ...system.relationships.requires.values()];
    return super._formatBadDependenciesTooltip(availability, data, deps);
  }
}

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

/**
 * A mapping of allowed package types and the classes which implement them.
 * @type {{world: World, system: System, module: Module}}
 */
const PACKAGE_TYPES = {
  world: World,
  system: System,
  module: Module
};

/**
 * A class responsible for managing defined game settings or settings menus.
 * Each setting is a string key/value pair belonging to a certain namespace and a certain store scope.
 *
 * When Foundry Virtual Tabletop is initialized, a singleton instance of this class is constructed within the global
 * Game object as game.settings.
 *
 * @see {@link Game#settings}
 * @see {@link Settings}
 * @see {@link SettingsConfig}
 */
class ClientSettings {
  constructor(worldSettings) {

    /**
     * A object of registered game settings for this scope
     * @type {Map<string, SettingsConfig>}
     */
    this.settings = new Map();

    /**
     * Registered settings menus which trigger secondary applications
     * @type {Map}
     */
    this.menus = new Map();

    /**
     * The storage interfaces used for persisting settings
     * Each storage interface shares the same API as window.localStorage
     */
    this.storage = new Map([
      ["client", window.localStorage],
      ["world", new WorldSettings(worldSettings)]
    ]);
  }

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

  /**
   * Return a singleton instance of the Game Settings Configuration app
   * @returns {SettingsConfig}
   */
  get sheet() {
    if ( !this._sheet ) this._sheet = new SettingsConfig();
    return this._sheet;
  }

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

  /**
   * Register a new game setting under this setting scope
   *
   * @param {string} namespace    The namespace under which the setting is registered
   * @param {string} key          The key name for the setting under the namespace
   * @param {SettingConfig} data  Configuration for setting data
   *
   * @example Register a client setting
   * ```js
   * game.settings.register("myModule", "myClientSetting", {
   *   name: "Register a Module Setting with Choices",
   *   hint: "A description of the registered setting and its behavior.",
   *   scope: "client",     // This specifies a client-stored setting
   *   config: true,        // This specifies that the setting appears in the configuration view
   *   requiresReload: true // This will prompt the user to reload the application for the setting to take effect.
   *   type: String,
   *   choices: {           // If choices are defined, the resulting setting will be a select menu
   *     "a": "Option A",
   *     "b": "Option B"
   *   },
   *   default: "a",        // The default value for the setting
   *   onChange: value => { // A callback function which triggers when the setting is changed
   *     console.log(value)
   *   }
   * });
   * ```
   *
   * @example Register a world setting
   * ```js
   * game.settings.register("myModule", "myWorldSetting", {
   *   name: "Register a Module Setting with a Range slider",
   *   hint: "A description of the registered setting and its behavior.",
   *   scope: "world",      // This specifies a world-level setting
   *   config: true,        // This specifies that the setting appears in the configuration view
   *   requiresReload: true // This will prompt the GM to have all clients reload the application for the setting to
   *                        // take effect.
   *   type: new foundry.fields.NumberField({nullable: false, min: 0, max: 100, step: 10}),
   *   default: 50,         // The default value for the setting
   *   onChange: value => { // A callback function which triggers when the setting is changed
   *     console.log(value)
   *   }
   * });
   * ```
   */
  register(namespace, key, data) {
    if ( !namespace || !key ) throw new Error("You must specify both namespace and key portions of the setting");
    data.key = key;
    data.namespace = namespace;
    data.scope = ["client", "world"].includes(data.scope) ? data.scope : "client";
    key = `${namespace}.${key}`;

    // Validate type
    if ( data.type ) {
      const allowedTypes = [foundry.data.fields.DataField, foundry.abstract.DataModel, Function];
      if ( !allowedTypes.some(t => data.type instanceof t) ) {
        throw new Error(`Setting ${key} type must be a DataField, DataModel, or callable function`);
      }

      // Sync some setting data with the DataField
      if ( data.type instanceof foundry.data.fields.DataField ) {
        data.default ??= data.type.initial;
        data.type.name = key;
        data.type.label ??= data.label;
        data.type.hint ??= data.hint;
      }
    }

    // Setting values may not be undefined, only null, so the default should also adhere to this behavior
    data.default ??= null;

    // Store the setting configuration
    this.settings.set(key, data);

    // Reinitialize to cast the value of the Setting into its defined type
    if ( data.scope === "world" ) this.storage.get("world").getSetting(key)?.reset();
  }

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

  /**
   * Register a new sub-settings menu
   *
   * @param {string} namespace           The namespace under which the menu is registered
   * @param {string} key                 The key name for the setting under the namespace
   * @param {SettingSubmenuConfig} data  Configuration for setting data
   *
   * @example Define a settings submenu which handles advanced configuration needs
   * ```js
   * game.settings.registerMenu("myModule", "mySettingsMenu", {
   *   name: "My Settings Submenu",
   *   label: "Settings Menu Label",      // The text label used in the button
   *   hint: "A description of what will occur in the submenu dialog.",
   *   icon: "fas fa-bars",               // A Font Awesome icon used in the submenu button
   *   type: MySubmenuApplicationClass,   // A FormApplication subclass which should be created
   *   restricted: true                   // Restrict this submenu to gamemaster only?
   * });
   * ```
   */
  registerMenu(namespace, key, data) {
    if ( !namespace || !key ) throw new Error("You must specify both namespace and key portions of the menu");
    data.key = `${namespace}.${key}`;
    data.namespace = namespace;
    if ( !((data.type?.prototype instanceof FormApplication)
        || (data.type?.prototype instanceof foundry.applications.api.ApplicationV2) )) {
      throw new Error("You must provide a menu type that is a FormApplication or ApplicationV2 instance or subclass");
    }
    this.menus.set(data.key, data);
  }

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

  /**
   * Get the value of a game setting for a certain namespace and setting key
   *
   * @param {string} namespace   The namespace under which the setting is registered
   * @param {string} key         The setting key to retrieve
   *
   * @example Retrieve the current setting value
   * ```js
   * game.settings.get("myModule", "myClientSetting");
   * ```
   */
  get(namespace, key) {
    key = this.#assertKey(namespace, key);
    const config = this.settings.get(key);
    const storage = this.storage.get(config.scope);

    // Get the Setting instance
    let setting;
    switch ( config.scope ) {
      case "client":
        setting = new Setting({key, value: storage.getItem(key) ?? config.default});
        break;
      case "world":
        setting = storage.getSetting(key);
        if ( !setting ) setting = new Setting({key, value: config.default});
        break;
    }
    return setting.value;
  }

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

  /**
   * Set the value of a game setting for a certain namespace and setting key
   *
   * @param {string} namespace    The namespace under which the setting is registered
   * @param {string} key          The setting key to retrieve
   * @param {*} value             The data to assign to the setting key
   * @param {object} [options]    Additional options passed to the server when updating world-scope settings
   * @returns {*}                 The assigned setting value
   *
   * @example Update the current value of a setting
   * ```js
   * game.settings.set("myModule", "myClientSetting", "b");
   * ```
   */
  async set(namespace, key, value, options={}) {
    key = this.#assertKey(namespace, key);
    const setting = this.settings.get(key);
    if ( value === undefined ) value = setting.default;

    // Assign using DataField
    if ( setting.type instanceof foundry.data.fields.DataField ) {
      const err = setting.type.validate(value, {fallback: false});
      if ( err instanceof foundry.data.validation.DataModelValidationFailure ) throw err.asError();
    }

    // Assign using DataModel
    if ( foundry.utils.isSubclass(setting.type, foundry.abstract.DataModel) ) {
      value = setting.type.fromSource(value, {strict: true});
    }

    // Save the setting change
    if ( setting.scope === "world" ) await this.#setWorld(key, value, options);
    else this.#setClient(key, value, setting.onChange);
    return value;
  }

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

  /**
   * Assert that the namespace and setting name were provided and form a valid key.
   * @param {string} namespace    The setting namespace
   * @param {string} settingName  The setting name
   * @returns {string}            The combined setting key
   */
  #assertKey(namespace, settingName) {
    const key = `${namespace}.${settingName}`;
    if ( !namespace || !settingName ) throw new Error("You must specify both namespace and key portions of the"
      + `setting, you provided "${key}"`);
    if ( !this.settings.has(key) ) throw new Error(`"${key}" is not a registered game setting`);
    return key;
  }

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

  /**
   * Create or update a Setting document in the World database.
   * @param {string} key          The setting key
   * @param {*} value             The desired setting value
   * @param {object} [options]    Additional options which are passed to the document creation or update workflows
   * @returns {Promise<Setting>}  The created or updated Setting document
   */
  async #setWorld(key, value, options) {
    if ( !game.ready ) throw new Error("You may not set a World-level Setting before the Game is ready.");
    const current = this.storage.get("world").getSetting(key);
    const json = JSON.stringify(value);
    if ( current ) return current.update({value: json}, options);
    else return Setting.create({key, value: json}, options);
  }

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

  /**
   * Create or update a Setting document in the browser client storage.
   * @param {string} key          The setting key
   * @param {*} value             The desired setting value
   * @param {Function} onChange   A registered setting onChange callback
   * @returns {Setting}           A Setting document which represents the created setting
   */
  #setClient(key, value, onChange) {
    const storage = this.storage.get("client");
    const json = JSON.stringify(value);
    let setting;
    if ( key in storage ) {
      setting = new Setting({key, value: storage.getItem(key)});
      const diff = setting.updateSource({value: json});
      if ( foundry.utils.isEmpty(diff) ) return setting;
    }
    else setting = new Setting({key, value: json});
    storage.setItem(key, json);
    if ( onChange instanceof Function ) onChange(value);
    return setting;
  }
}

/**
 * A standardized way socket messages are dispatched and their responses are handled
 */
class SocketInterface {
  /**
   * Send a socket request to all other clients and handle their responses.
   * @param {string} eventName          The socket event name being handled
   * @param {DocumentSocketRequest|object} request  Request data provided to the Socket event
   * @returns {Promise<SocketResponse>} A Promise which resolves to the SocketResponse
   */
  static dispatch(eventName, request) {
    return new Promise((resolve, reject) => {
      game.socket.emit(eventName, request, response => {
        if ( response.error ) {
          const err = SocketInterface.#handleError(response.error);
          reject(err);
        }
        else resolve(response);
      });
    });
  }

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

  /**
   * Handle an error returned from the database, displaying it on screen and in the console
   * @param {Error} err   The provided Error message
   */
  static #handleError(err) {
    let error = err instanceof Error ? err : new Error(err.message);
    if ( err.stack ) error.stack = err.stack;
    if ( ui.notifications ) ui.notifications.error(error.message);
    return error;
  }
}

/**
 * A collection of functions related to sorting objects within a parent container.
 */
class SortingHelpers {

  /**
   * Given a source object to sort, a target to sort relative to, and an Array of siblings in the container:
   * Determine the updated sort keys for the source object, or all siblings if a reindex is required.
   * Return an Array of updates to perform, it is up to the caller to dispatch these updates.
   * Each update is structured as:
   * {
   *   target: object,
   *   update: {sortKey: sortValue}
   * }
   *
   * @param {object} source       The source object being sorted
   * @param {object} [options]    Options which modify the sort behavior
   * @param {object|null} [options.target]  The target object relative which to sort
   * @param {object[]} [options.siblings]   The Array of siblings which the source should be sorted within
   * @param {string} [options.sortKey=sort] The property name within the source object which defines the sort key
   * @param {boolean} [options.sortBefore]  Explicitly sort before (true) or sort after( false).
   *                                        If undefined the sort order will be automatically determined.
   * @returns {object[]}          An Array of updates for the caller of the helper function to perform
   */
  static performIntegerSort(source, {target=null, siblings=[], sortKey="sort", sortBefore}={}) {

    // Automatically determine the sorting direction
    if ( sortBefore === undefined ) {
      sortBefore = (source[sortKey] || 0) > (target?.[sortKey] || 0);
    }

    // Ensure the siblings are sorted
    siblings = Array.from(siblings);
    siblings.sort((a, b) => a[sortKey] - b[sortKey]);

    // Determine the index target for the sort
    let defaultIdx = sortBefore ? siblings.length : 0;
    let idx = target ? siblings.findIndex(sib => sib === target) : defaultIdx;

    // Determine the indices to sort between
    let min, max;
    if ( sortBefore ) [min, max] = this._sortBefore(siblings, idx, sortKey);
    else [min, max] = this._sortAfter(siblings, idx, sortKey);

    // Easiest case - no siblings
    if ( siblings.length === 0 ) {
      return [{
        target: source,
        update: {[sortKey]: CONST.SORT_INTEGER_DENSITY}
      }];
    }

    // No minimum - sort to beginning
    else if ( Number.isFinite(max) && (min === null) ) {
      return [{
        target: source,
        update: {[sortKey]: max - CONST.SORT_INTEGER_DENSITY}
      }];
    }

    // No maximum - sort to end
    else if ( Number.isFinite(min) && (max === null) ) {
      return [{
        target: source,
        update: {[sortKey]: min + CONST.SORT_INTEGER_DENSITY}
      }];
    }

    // Sort between two
    else if ( Number.isFinite(min) && Number.isFinite(max) && (Math.abs(max - min) > 1) ) {
      return [{
        target: source,
        update: {[sortKey]: Math.round(0.5 * (min + max))}
      }];
    }

    // Reindex all siblings
    else {
      siblings.splice(idx + (sortBefore ? 0 : 1), 0, source);
      return siblings.map((sib, i) => {
        return {
          target: sib,
          update: {[sortKey]: (i+1) * CONST.SORT_INTEGER_DENSITY}
        }
      });
    }
  }

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

  /**
   * Given an ordered Array of siblings and a target position, return the [min,max] indices to sort before the target
   * @private
   */
  static _sortBefore(siblings, idx, sortKey) {
    let max = siblings[idx] ? siblings[idx][sortKey] : null;
    let min = siblings[idx-1] ? siblings[idx-1][sortKey] : null;
    return [min, max];
  }

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

  /**
   * Given an ordered Array of siblings and a target position, return the [min,max] indices to sort after the target
   * @private
   */
  static _sortAfter(siblings, idx, sortKey) {
    let min = siblings[idx] ? siblings[idx][sortKey] : null;
    let max = siblings[idx+1] ? siblings[idx+1][sortKey] : null;
    return [min, max];
  }

  /* -------------------------------------------- */
}

/**
 * A singleton class {@link game#time} which keeps the official Server and World time stamps.
 * Uses a basic implementation of https://www.geeksforgeeks.org/cristians-algorithm/ for synchronization.
 */
class GameTime {
  constructor(socket) {

    /**
     * The most recently synchronized timestamps retrieved from the server.
     * @type {{clientTime: number, serverTime: number, worldTime: number}}
     */
    this._time = {};

    /**
     * The average one-way latency across the most recent 5 trips
     * @type {number}
     */
    this._dt = 0;

    /**
     * The most recent five synchronization durations
     * @type {number[]}
     */
    this._dts = [];

    // Perform an initial sync
    if ( socket ) this.sync(socket);
  }

  /**
   * The amount of time to delay before re-syncing the official server time.
   * @type {number}
   */
  static SYNC_INTERVAL_MS = 1000 * 60 * 5;

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

  /**
   * The current server time based on the last synchronization point and the approximated one-way latency.
   * @type {number}
   */
  get serverTime() {
    const t1 = Date.now();
    const dt = t1 - this._time.clientTime;
    if ( dt > GameTime.SYNC_INTERVAL_MS ) this.sync();
    return this._time.serverTime + dt;
  }

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

  /**
   * The current World time based on the last recorded value of the core.time setting
   * @type {number}
   */
  get worldTime() {
    return this._time.worldTime;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Advance the game time by a certain number of seconds
   * @param {number} seconds        The number of seconds to advance (or rewind if negative) by
   * @param {object} [options]      Additional options passed to game.settings.set
   * @returns {Promise<number>}     The new game time
   */
  async advance(seconds, options) {
    return game.settings.set("core", "time", this.worldTime + seconds, options);
  }

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

  /**
   * Synchronize the local client game time with the official time kept by the server
   * @param {Socket} socket         The connected server Socket instance
   * @returns {Promise<GameTime>}
   */
  async sync(socket) {
    socket = socket ?? game.socket;

    // Get the official time from the server
    const t0 = Date.now();
    const time = await new Promise(resolve => socket.emit("time", resolve));
    const t1 = Date.now();

    // Adjust for trip duration
    if ( this._dts.length >= 5 ) this._dts.unshift();
    this._dts.push(t1 - t0);

    // Re-compute the average one-way duration
    this._dt = Math.round(this._dts.reduce((total, t) => total + t, 0) / (this._dts.length * 2));

    // Adjust the server time and return the adjusted time
    time.clientTime = t1 - this._dt;
    this._time = time;
    console.log(`${vtt} | Synchronized official game time in ${this._dt}ms`);
    return this;
  }

  /* -------------------------------------------- */
  /*  Event Handlers and Callbacks                */
  /* -------------------------------------------- */

  /**
   * Handle follow-up actions when the official World time is changed
   * @param {number} worldTime      The new canonical World time.
   * @param {object} options        Options passed from the requesting client where the change was made
   * @param {string} userId         The ID of the User who advanced the time
   */
  onUpdateWorldTime(worldTime, options, userId) {
    const dt = worldTime - this._time.worldTime;
    this._time.worldTime = worldTime;
    Hooks.callAll("updateWorldTime", worldTime, dt, options, userId);
    if ( CONFIG.debug.time ) console.log(`The world time advanced by ${dt} seconds, and is now ${worldTime}.`);
  }
}

/**
 * A singleton Tooltip Manager class responsible for rendering and positioning a dynamic tooltip element which is
 * accessible as `game.tooltip`.
 *
 * @see {@link Game.tooltip}
 *
 * @example API Usage
 * ```js
 * game.tooltip.activate(htmlElement, {text: "Some tooltip text", direction: "UP"});
 * game.tooltip.deactivate();
 * ```
 *
 * @example HTML Usage
 * ```html
 * <span data-tooltip="Some Tooltip" data-tooltip-direction="LEFT">I have a tooltip</span>
 * <ol data-tooltip-direction="RIGHT">
 *   <li data-tooltip="The First One">One</li>
 *   <li data-tooltip="The Second One">Two</li>
 *   <li data-tooltip="The Third One">Three</li>
 * </ol>
 * ```
 */
class TooltipManager {

  /**
   * A cached reference to the global tooltip element
   * @type {HTMLElement}
   */
  tooltip = document.getElementById("tooltip");

  /**
   * A reference to the HTML element which is currently tool-tipped, if any.
   * @type {HTMLElement|null}
   */
  element = null;

  /**
   * An amount of margin which is used to offset tooltips from their anchored element.
   * @type {number}
   */
  static TOOLTIP_MARGIN_PX = 5;

  /**
   * The number of milliseconds delay which activates a tooltip on a "long hover".
   * @type {number}
   */
  static TOOLTIP_ACTIVATION_MS = 500;

  /**
   * The directions in which a tooltip can extend, relative to its tool-tipped element.
   * @enum {string}
   */
  static TOOLTIP_DIRECTIONS = {
    UP: "UP",
    DOWN: "DOWN",
    LEFT: "LEFT",
    RIGHT: "RIGHT",
    CENTER: "CENTER"
  };

  /**
   * The number of pixels buffer around a locked tooltip zone before they should be dismissed.
   * @type {number}
   */
  static LOCKED_TOOLTIP_BUFFER_PX = 50;

  /**
   * Is the tooltip currently active?
   * @type {boolean}
   */
  #active = false;

  /**
   * A reference to a window timeout function when an element is activated.
   */
  #activationTimeout;

  /**
   * A reference to a window timeout function when an element is deactivated.
   */
  #deactivationTimeout;

  /**
   * An element which is pending tooltip activation if hover is sustained
   * @type {HTMLElement|null}
   */
  #pending;

  /**
   * Maintain state about active locked tooltips in order to perform appropriate automatic dismissal.
   * @type {{elements: Set<HTMLElement>, boundingBox: Rectangle}}
   */
  #locked = {
    elements: new Set(),
    boundingBox: {}
  };

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

  /**
   * Activate interactivity by listening for hover events on HTML elements which have a data-tooltip defined.
   */
  activateEventListeners() {
    document.body.addEventListener("pointerenter", this.#onActivate.bind(this), true);
    document.body.addEventListener("pointerleave", this.#onDeactivate.bind(this), true);
    document.body.addEventListener("pointerup", this._onLockTooltip.bind(this), true);
    document.body.addEventListener("pointermove", this.#testLockedTooltipProximity.bind(this), {
      capture: true,
      passive: true
    });
  }

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

  /**
   * Handle hover events which activate a tooltipped element.
   * @param {PointerEvent} event    The initiating pointerenter event
   */
  #onActivate(event) {
    if ( Tour.tourInProgress ) return; // Don't activate tooltips during a tour
    const element = event.target;
    if ( element.closest(".editor-content.ProseMirror") ) return; // Don't activate tooltips inside text editors.
    if ( !element.dataset.tooltip ) {
      // Check if the element has moved out from underneath the cursor and pointerenter has fired on a non-child of the
      // tooltipped element.
      if ( this.#active && !this.element.contains(element) ) this.#startDeactivation();
      return;
    }

    // Don't activate tooltips if the element contains an active context menu or is in a matching link tooltip
    if ( element.matches("#context-menu") || element.querySelector("#context-menu") ) return;

    // If the tooltip is currently active, we can move it to a new element immediately
    if ( this.#active ) {
      this.activate(element);
      return;
    }

    // Clear any existing deactivation workflow
    this.#clearDeactivation();

    // Delay activation to determine user intent
    this.#pending = element;
    this.#activationTimeout = window.setTimeout(() => {
      this.#activationTimeout = null;
      if ( this.#pending ) this.activate(this.#pending);
    }, this.constructor.TOOLTIP_ACTIVATION_MS);
  }

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

  /**
   * Handle hover events which deactivate a tooltipped element.
   * @param {PointerEvent} event    The initiating pointerleave event
   */
  #onDeactivate(event) {
    if ( event.target !== (this.element ?? this.#pending) ) return;
    const parent = event.target.parentElement.closest("[data-tooltip]");
    if ( parent ) this.activate(parent);
    else this.#startDeactivation();
  }

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

  /**
   * Start the deactivation process.
   */
  #startDeactivation() {
    if ( this.#deactivationTimeout ) return;

    // Clear any existing activation workflow
    this.clearPending();

    // Delay deactivation to confirm whether some new element is now pending
    this.#deactivationTimeout = window.setTimeout(() => {
      this.#deactivationTimeout = null;
      if ( !this.#pending ) this.deactivate();
    }, this.constructor.TOOLTIP_ACTIVATION_MS);
  }

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

  /**
   * Clear any existing deactivation workflow.
   */
  #clearDeactivation() {
    window.clearTimeout(this.#deactivationTimeout);
    this.#deactivationTimeout = null;
  }

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

  /**
   * Activate the tooltip for a hovered HTML element which defines a tooltip localization key.
   * @param {HTMLElement} element         The HTML element being hovered.
   * @param {object} [options={}]         Additional options which can override tooltip behavior.
   * @param {string} [options.text]       Explicit tooltip text to display. If this is not provided the tooltip text is
   *                                      acquired from the elements data-tooltip attribute. This text will be
   *                                      automatically localized
   * @param {TooltipManager.TOOLTIP_DIRECTIONS} [options.direction]  An explicit tooltip expansion direction. If this
   *                                      is not provided the direction is acquired from the data-tooltip-direction
   *                                      attribute of the element or one of its parents.
   * @param {string} [options.cssClass]   An optional, space-separated list of CSS classes to apply to the activated
   *                                      tooltip. If this is not provided, the CSS classes are acquired from the
   *                                      data-tooltip-class attribute of the element or one of its parents.
   * @param {boolean} [options.locked]    An optional boolean to lock the tooltip after creation. Defaults to false.
   * @param {HTMLElement} [options.content]  Explicit HTML content to inject into the tooltip rather than using tooltip
   *                                         text.
   */
  activate(element, {text, direction, cssClass, locked=false, content}={}) {
    if ( text && content ) throw new Error("Cannot provide both text and content options to TooltipManager#activate.");
    // Deactivate currently active element
    this.deactivate();
    // Check if the element still exists in the DOM.
    if ( !document.body.contains(element) ) return;
    // Mark the new element as active
    this.#active = true;
    this.element = element;
    element.setAttribute("aria-describedby", "tooltip");
    if ( content ) {
      this.tooltip.innerHTML = ""; // Clear existing content.
      this.tooltip.appendChild(content);
    }
    else this.tooltip.innerHTML = text || game.i18n.localize(element.dataset.tooltip);

    // Activate display of the tooltip
    this.tooltip.removeAttribute("class");
    this.tooltip.classList.add("active");
    cssClass ??= element.closest("[data-tooltip-class]")?.dataset.tooltipClass;
    if ( cssClass ) this.tooltip.classList.add(...cssClass.split(" "));

    // Set tooltip position
    direction ??= element.closest("[data-tooltip-direction]")?.dataset.tooltipDirection;
    if ( !direction ) direction = this._determineDirection();
    this._setAnchor(direction);

    if ( locked || element.dataset.hasOwnProperty("locked") ) this.lockTooltip();
  }

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

  /**
   * Deactivate the tooltip from a previously hovered HTML element.
   */
  deactivate() {
    // Deactivate display of the tooltip
    this.#active = false;
    this.tooltip.classList.remove("active");

    // Clear any existing (de)activation workflow
    this.clearPending();
    this.#clearDeactivation();

    // Update the tooltipped element
    if ( !this.element ) return;
    this.element.removeAttribute("aria-describedby");
    this.element = null;
  }

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

  /**
   * Clear any pending activation workflow.
   * @internal
   */
  clearPending() {
    window.clearTimeout(this.#activationTimeout);
    this.#pending = this.#activationTimeout = null;
  }

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

  /**
   * Lock the current tooltip.
   * @returns {HTMLElement}
   */
  lockTooltip() {
    const clone = this.tooltip.cloneNode(false);
    // Steal the content from the original tooltip rather than cloning it, so that listeners are preserved.
    while ( this.tooltip.firstChild ) clone.appendChild(this.tooltip.firstChild);
    clone.removeAttribute("id");
    clone.classList.add("locked-tooltip", "active");
    document.body.appendChild(clone);
    this.deactivate();
    clone.addEventListener("contextmenu", this._onLockedTooltipDismiss.bind(this));
    this.#locked.elements.add(clone);

    // If the tooltip's contents were injected via setting innerHTML, then immediately requesting the bounding box will
    // return incorrect values as the browser has not had a chance to reflow yet. For that reason we defer computing the
    // bounding box until the next frame.
    requestAnimationFrame(() => this.#computeLockedBoundingBox());
    return clone;
  }

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

  /**
   * Handle a request to lock the current tooltip.
   * @param {MouseEvent} event  The click event.
   * @protected
   */
  _onLockTooltip(event) {
    if ( (event.button !== 1) || !this.#active || Tour.tourInProgress ) return;
    event.preventDefault();
    this.lockTooltip();
  }

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

  /**
   * Handle dismissing a locked tooltip.
   * @param {MouseEvent} event  The click event.
   * @protected
   */
  _onLockedTooltipDismiss(event) {
    event.preventDefault();
    const target = event.currentTarget;
    this.dismissLockedTooltip(target);
  }

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

  /**
   * Dismiss a given locked tooltip.
   * @param {HTMLElement} element  The locked tooltip to dismiss.
   */
  dismissLockedTooltip(element) {
    this.#locked.elements.delete(element);
    element.remove();
    this.#computeLockedBoundingBox();
  }

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

  /**
   * Compute the unified bounding box from the set of locked tooltip elements.
   */
  #computeLockedBoundingBox() {
    let bb = null;
    for ( const element of this.#locked.elements.values() ) {
      const {x, y, width, height} = element.getBoundingClientRect();
      const rect = new PIXI.Rectangle(x, y, width, height);
      if ( bb ) bb.enlarge(rect);
      else bb = rect;
    }
    this.#locked.boundingBox = bb;
  }

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

  /**
   * Check whether the user is moving away from the locked tooltips and dismiss them if so.
   * @param {MouseEvent} event  The mouse move event.
   */
  #testLockedTooltipProximity(event) {
    if ( !this.#locked.elements.size ) return;
    const {clientX: x, clientY: y, movementX, movementY} = event;
    const buffer = this.#locked.boundingBox?.clone?.().pad(this.constructor.LOCKED_TOOLTIP_BUFFER_PX);

    // If the cursor is close enough to the bounding box, or we have no movement information, do nothing.
    if ( !buffer || buffer.contains(x, y) || !Number.isFinite(movementX) || !Number.isFinite(movementY) ) return;

    // Otherwise, check if the cursor is moving away from the tooltip, and dismiss it if so.
    if ( ((movementX > 0) && (x > buffer.right))
      || ((movementX < 0) && (x < buffer.x))
      || ((movementY > 0) && (y > buffer.bottom))
      || ((movementY < 0) && (y < buffer.y)) ) this.dismissLockedTooltips();
  }

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

  /**
   * Dismiss the set of active locked tooltips.
   */
  dismissLockedTooltips() {
    for ( const element of this.#locked.elements.values() ) {
      element.remove();
    }
    this.#locked.elements = new Set();
  }

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

  /**
   * Create a locked tooltip at the given position.
   * @param {object} position             A position object with coordinates for where the tooltip should be placed
   * @param {string} position.top         Explicit top position for the tooltip
   * @param {string} position.right       Explicit right position for the tooltip
   * @param {string} position.bottom      Explicit bottom position for the tooltip
   * @param {string} position.left        Explicit left position for the tooltip
   * @param {string} text                 Explicit tooltip text or HTML to display.
   * @param {object} [options={}]         Additional options which can override tooltip behavior.
   * @param {array} [options.cssClass]    An optional, space-separated list of CSS classes to apply to the activated
   *                                      tooltip.
   * @returns {HTMLElement}
   */
  createLockedTooltip(position, text, {cssClass}={}) {
    this.#clearDeactivation();
    this.tooltip.innerHTML = text;
    this.tooltip.style.top = position.top || "";
    this.tooltip.style.right = position.right || "";
    this.tooltip.style.bottom = position.bottom || "";
    this.tooltip.style.left = position.left || "";

    const clone = this.lockTooltip();
    if ( cssClass ) clone.classList.add(...cssClass.split(" "));
    return clone;
  }

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

  /**
   * If an explicit tooltip expansion direction was not specified, figure out a valid direction based on the bounds
   * of the target element and the screen.
   * @protected
   */
  _determineDirection() {
    const pos = this.element.getBoundingClientRect();
    const dirs = this.constructor.TOOLTIP_DIRECTIONS;
    return dirs[pos.y + this.tooltip.offsetHeight > window.innerHeight ? "UP" : "DOWN"];
  }

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

  /**
   * Set tooltip position relative to an HTML element using an explicitly provided data-tooltip-direction.
   * @param {TooltipManager.TOOLTIP_DIRECTIONS} direction  The tooltip expansion direction specified by the element
   *                                                        or a parent element.
   * @protected
   */
  _setAnchor(direction) {
    const directions = this.constructor.TOOLTIP_DIRECTIONS;
    const pad = this.constructor.TOOLTIP_MARGIN_PX;
    const pos = this.element.getBoundingClientRect();
    let style = {};
    switch ( direction ) {
      case directions.DOWN:
        style.textAlign = "center";
        style.left = pos.left - (this.tooltip.offsetWidth / 2) + (pos.width / 2);
        style.top = pos.bottom + pad;
        break;
      case directions.LEFT:
        style.textAlign = "left";
        style.right = window.innerWidth - pos.left + pad;
        style.top = pos.top + (pos.height / 2) - (this.tooltip.offsetHeight / 2);
        break;
      case directions.RIGHT:
        style.textAlign = "right";
        style.left = pos.right + pad;
        style.top = pos.top + (pos.height / 2) - (this.tooltip.offsetHeight / 2);
        break;
      case directions.UP:
        style.textAlign = "center";
        style.left = pos.left - (this.tooltip.offsetWidth / 2) + (pos.width / 2);
        style.bottom = window.innerHeight - pos.top + pad;
        break;
      case directions.CENTER:
        style.textAlign = "center";
        style.left = pos.left - (this.tooltip.offsetWidth / 2) + (pos.width / 2);
        style.top = pos.top + (pos.height / 2) - (this.tooltip.offsetHeight / 2);
        break;
    }
    return this._setStyle(style);
  }

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

  /**
   * Apply inline styling rules to the tooltip for positioning and text alignment.
   * @param {object} [position={}]  An object of positioning data, supporting top, right, bottom, left, and textAlign
   * @protected
   */
  _setStyle(position={}) {
    const pad = this.constructor.TOOLTIP_MARGIN_PX;
    position = {top: null, right: null, bottom: null, left: null, textAlign: "left", ...position};
    const style = this.tooltip.style;

    // Left or Right
    const maxW = window.innerWidth - this.tooltip.offsetWidth;
    if ( position.left ) position.left = Math.clamp(position.left, pad, maxW - pad);
    if ( position.right ) position.right = Math.clamp(position.right, pad, maxW - pad);

    // Top or Bottom
    const maxH = window.innerHeight - this.tooltip.offsetHeight;
    if ( position.top ) position.top = Math.clamp(position.top, pad, maxH - pad);
    if ( position.bottom ) position.bottom = Math.clamp(position.bottom, pad, maxH - pad);

    // Assign styles
    for ( let k of ["top", "right", "bottom", "left"] ) {
      const v = position[k];
      style[k] = v ? `${v}px` : null;
    }

    this.tooltip.classList.remove(...["center", "left", "right"].map(dir => `text-${dir}`));
    this.tooltip.classList.add(`text-${position.textAlign}`);
  }
}

/**
 * @typedef {Object} TourStep               A step in a Tour
 * @property {string} id                    A machine-friendly id of the Tour Step
 * @property {string} title                 The title of the step, displayed in the tooltip header
 * @property {string} content               Raw HTML content displayed during the step
 * @property {string} [selector]            A DOM selector which denotes an element to highlight during this step.
 *                                          If omitted, the step is displayed in the center of the screen.
 * @property {TooltipManager.TOOLTIP_DIRECTIONS} [tooltipDirection]  How the tooltip for the step should be displayed
 *                                          relative to the target element. If omitted, the best direction will be attempted to be auto-selected.
 * @property {boolean} [restricted]         Whether the Step is restricted to the GM only. Defaults to false.
 */

/**
 * @typedef {Object} TourConfig               Tour configuration data
 * @property {string} namespace               The namespace this Tour belongs to. Typically, the name of the package which
 *                                            implements the tour should be used
 * @property {string} id                      A machine-friendly id of the Tour, must be unique within the provided namespace
 * @property {string} title                   A human-readable name for this Tour. Localized.
 * @property {TourStep[]} steps               The list of Tour Steps
 * @property {string} [description]           A human-readable description of this Tour. Localized.
 * @property {object} [localization]          A map of localizations for the Tour that should be merged into the default localizations
 * @property {boolean} [restricted]           Whether the Tour is restricted to the GM only. Defaults to false.
 * @property {boolean} [display]              Whether the Tour should be displayed in the Manage Tours UI. Defaults to false.
 * @property {boolean} [canBeResumed]         Whether the Tour can be resumed or if it always needs to start from the beginning. Defaults to false.
 * @property {string[]} [suggestedNextTours]  A list of namespaced Tours that might be suggested to the user when this Tour is completed.
 *                                            The first non-completed Tour in the array will be recommended.
 */

/**
 * A Tour that shows a series of guided steps.
 * @param {TourConfig} config           The configuration of the Tour
 * @tutorial tours
 */
class Tour {
  constructor(config, {id, namespace}={}) {
    this.config = foundry.utils.deepClone(config);
    if ( this.config.localization ) foundry.utils.mergeObject(game.i18n._fallback, this.config.localization);
    this.#id = id ?? config.id;
    this.#namespace = namespace ?? config.namespace;
    this.#stepIndex = this._loadProgress();
  }

  /**
   * A singleton reference which tracks the currently active Tour.
   * @type {Tour|null}
   */
  static #activeTour = null;

  /**
   * @enum {string}
   */
  static STATUS = {
    UNSTARTED: "unstarted",
    IN_PROGRESS: "in-progress",
    COMPLETED: "completed"
  };

  /**
   * Indicates if a Tour is currently in progress.
   * @returns {boolean}
   */
  static get tourInProgress() {
    return !!Tour.#activeTour;
  }

  /**
   * Returns the active Tour, if any
   * @returns {Tour|null}
   */
  static get activeTour() {
    return Tour.#activeTour;
  }

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

  /**
   * Handle a movement action to either progress or regress the Tour.
   * @param @param {string[]} movementDirections           The Directions being moved in
   * @returns {boolean}
   */
  static onMovementAction(movementDirections) {
    if ( (movementDirections.includes(ClientKeybindings.MOVEMENT_DIRECTIONS.RIGHT))
      && (Tour.activeTour.hasNext) ) {
      Tour.activeTour.next();
      return true;
    }
    else if ( (movementDirections.includes(ClientKeybindings.MOVEMENT_DIRECTIONS.LEFT))
      && (Tour.activeTour.hasPrevious) ) {
      Tour.activeTour.previous();
      return true;
    }
  }

  /**
   * Configuration of the tour. This object is cloned to avoid mutating the original configuration.
   * @type {TourConfig}
   */
  config;

  /**
   * The HTMLElement which is the focus of the current tour step.
   * @type {HTMLElement}
   */
  targetElement;

  /**
   * The HTMLElement that fades out the rest of the screen
   * @type {HTMLElement}
   */
  fadeElement;

  /**
   * The HTMLElement that blocks input while a Tour is active
   */
  overlayElement;

  /**
   * Padding around a Highlighted Element
   * @type {number}
   */
  static HIGHLIGHT_PADDING = 10;

  /**
   * The unique identifier of the tour.
   * @type {string}
   */
  get id() {
    return this.#id;
  }

  set id(value) {
    if ( this.#id ) throw new Error("The Tour has already been assigned an ID");
    this.#id = value;
  }

  #id;

  /**
   * The human-readable title for the tour.
   * @type {string}
   */
  get title() {
    return game.i18n.localize(this.config.title);
  }

  /**
   * The human-readable description of the tour.
   * @type {string}
   */
  get description() {
    return game.i18n.localize(this.config.description);
  }

  /**
   * The package namespace for the tour.
   * @type {string}
   */
  get namespace() {
    return this.#namespace;
  }

  set namespace(value) {
    if ( this.#namespace ) throw new Error("The Tour has already been assigned a namespace");
    this.#namespace = value;
  }

  #namespace;

  /**
   * The key the Tour is stored under in game.tours, of the form `${namespace}.${id}`
   * @returns {string}
   */
  get key() {
    return `${this.#namespace}.${this.#id}`;
  }

  /**
   * The configuration of tour steps
   * @type {TourStep[]}
   */
  get steps() {
    return this.config.steps.filter(step => !step.restricted || game.user.isGM);
  }

  /**
   * Return the current Step, or null if the tour has not yet started.
   * @type {TourStep|null}
   */
  get currentStep() {
    return this.steps[this.#stepIndex] ?? null;
  }

  /**
   * The index of the current step; -1 if the tour has not yet started, or null if the tour is finished.
   * @type {number|null}
   */
  get stepIndex() {
    return this.#stepIndex;
  }

  /** @private */
  #stepIndex = -1;

  /**
   * Returns True if there is a next TourStep
   * @type {boolean}
   */
  get hasNext() {
    return this.#stepIndex < this.steps.length - 1;
  }

  /**
   * Returns True if there is a previous TourStep
   * @type {boolean}
   */
  get hasPrevious() {
    return this.#stepIndex > 0;
  }

  /**
   * Return whether this Tour is currently eligible to be started?
   * This is useful for tours which can only be used in certain circumstances, like if the canvas is active.
   * @type {boolean}
   */
  get canStart() {
    return true;
  }

  /**
   * The current status of the Tour
   * @returns {STATUS}
   */
  get status() {
    if ( this.#stepIndex === -1 ) return Tour.STATUS.UNSTARTED;
    else if (this.#stepIndex === this.steps.length) return Tour.STATUS.COMPLETED;
    else return Tour.STATUS.IN_PROGRESS;
  }

  /* -------------------------------------------- */
  /*  Tour Methods                                */
  /* -------------------------------------------- */

  /**
   * Advance the tour to a completed state.
   */
  async complete() {
    return this.progress(this.steps.length);
  }

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

  /**
   * Exit the tour at the current step.
   */
  exit() {
    if ( this.currentStep ) this._postStep();
    Tour.#activeTour = null;
  }

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

  /**
   * Reset the Tour to an un-started state.
   */
  async reset() {
    return this.progress(-1);
  }

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

  /**
   * Start the Tour at its current step, or at the beginning if the tour has not yet been started.
   */
  async start() {
    game.tooltip.clearPending();
    switch ( this.status ) {
      case Tour.STATUS.IN_PROGRESS:
        return this.progress((this.config.canBeResumed && this.hasPrevious) ? this.#stepIndex : 0);
      case Tour.STATUS.UNSTARTED:
      case Tour.STATUS.COMPLETED:
        return this.progress(0);
    }
  }

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

  /**
   * Progress the Tour to the next step.
   */
  async next() {
    if ( this.status === Tour.STATUS.COMPLETED ) {
      throw new Error(`Tour ${this.id} has already been completed`);
    }
    if ( !this.hasNext ) return this.complete();
    return this.progress(this.#stepIndex + 1);
  }

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

  /**
   * Rewind the Tour to the previous step.
   */
  async previous() {
    if ( !this.hasPrevious ) return;
    return this.progress(this.#stepIndex - 1);
  }

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

  /**
   * Progresses to a given Step
   * @param {number} stepIndex  The step to progress to
   */
  async progress(stepIndex) {

    // Ensure we are provided a valid tour step
    if ( !Number.between(stepIndex, -1, this.steps.length) ) {
      throw new Error(`Step index ${stepIndex} is not valid for Tour ${this.id} with ${this.steps.length} steps.`);
    }

    // Ensure that only one Tour is active at a given time
    if ( Tour.#activeTour && (Tour.#activeTour !== this) ) {
      if ( (stepIndex !== -1) && (stepIndex !== this.steps.length) ) throw new Error(`You cannot begin the ${this.title} Tour because the `
      + `${Tour.#activeTour.title} Tour is already in progress`);
      else Tour.#activeTour = null;
    }
    else Tour.#activeTour = this;

    // Tear down the prior step
    if ( stepIndex > 0 ) {
      await this._postStep();
      console.debug(`Tour [${this.namespace}.${this.id}] | Completed step ${this.#stepIndex+1} of ${this.steps.length}`);
    }

    // Change the step and save progress
    this.#stepIndex = stepIndex;
    this._saveProgress();

    // If the TourManager is active, update the UI
    const tourManager = Object.values(ui.windows).find(x => x instanceof ToursManagement);
    if ( tourManager ) {
      tourManager._cachedData = null;
      tourManager._render(true);
    }

    if ( this.status === Tour.STATUS.UNSTARTED ) return Tour.#activeTour = null;
    if ( this.status === Tour.STATUS.COMPLETED ) {
      Tour.#activeTour = null;
      const suggestedTour = game.tours.get((this.config.suggestedNextTours || []).find(tourId => {
        const tour = game.tours.get(tourId);
        return tour && (tour.status !== Tour.STATUS.COMPLETED);
      }));

      if ( !suggestedTour ) return;
      return Dialog.confirm({
        title: game.i18n.localize("TOURS.SuggestedTitle"),
        content: game.i18n.format("TOURS.SuggestedDescription", { currentTitle: this.title, nextTitle: suggestedTour.title }),
        yes: () => suggestedTour.start(),
        defaultYes: true
      });
    }

    // Set up the next step
    await this._preStep();

    // Identify the target HTMLElement
    this.targetElement = null;
    const step = this.currentStep;
    if ( step.selector ) {
      this.targetElement = this._getTargetElement(step.selector);
      if ( !this.targetElement ) console.warn(`Tour [${this.id}] target element "${step.selector}" was not found`);
    }

    // Display the step
    try {
      await this._renderStep();
    }
    catch(e) {
      this.exit();
      throw e;
    }
  }

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

  /**
   * Query the DOM for the target element using the provided selector
   * @param {string} selector     A CSS selector
   * @returns {Element|null}      The target element, or null if not found
   * @protected
   */
  _getTargetElement(selector) {
    return document.querySelector(selector);
  }

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

  /**
   * Creates and returns a Tour by loading a JSON file
   * @param {string} filepath   The path to the JSON file
   * @returns {Promise<Tour>}
   */
  static async fromJSON(filepath) {
    const json = await foundry.utils.fetchJsonWithTimeout(foundry.utils.getRoute(filepath, {prefix: ROUTE_PREFIX}));
    return new this(json);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Set-up operations performed before a step is shown.
   * @abstract
   * @protected
   */
  async _preStep() {}

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

  /**
   * Clean-up operations performed after a step is completed.
   * @abstract
   * @protected
   */
  async _postStep() {
    if ( this.currentStep && !this.currentStep.selector ) this.targetElement?.remove();
    else game.tooltip.deactivate();
    if ( this.fadeElement ) {
      this.fadeElement.remove();
      this.fadeElement = undefined;
    }
    if ( this.overlayElement ) this.overlayElement = this.overlayElement.remove();
  }

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

  /**
   * Renders the current Step of the Tour
   * @protected
   */
  async _renderStep() {
    const step = this.currentStep;
    const data = {
      title: game.i18n.localize(step.title),
      content: game.i18n.localize(step.content).split("\n"),
      step: this.#stepIndex + 1,
      totalSteps: this.steps.length,
      hasNext: this.hasNext,
      hasPrevious: this.hasPrevious
    };
    const content = await renderTemplate("templates/apps/tour-step.html", data);

    if ( step.selector ) {
      if ( !this.targetElement ) {
        throw new Error(`The expected targetElement ${step.selector} does not exist`);
      }
      this.targetElement.scrollIntoView();
      game.tooltip.activate(this.targetElement, {text: content, cssClass: "tour", direction: step.tooltipDirection});
    }
    else {
      // Display a general mid-screen Step
      const wrapper = document.createElement("aside");
      wrapper.innerHTML = content;
      wrapper.classList.add("tour-center-step");
      wrapper.classList.add("tour");
      document.body.appendChild(wrapper);
      this.targetElement = wrapper;
    }

    // Fade out rest of screen
    this.fadeElement = document.createElement("div");
    this.fadeElement.classList.add("tour-fadeout");
    const targetBoundingRect = this.targetElement.getBoundingClientRect();

    this.fadeElement.style.width = `${targetBoundingRect.width + (step.selector ? Tour.HIGHLIGHT_PADDING : 0)}px`;
    this.fadeElement.style.height = `${targetBoundingRect.height + (step.selector ? Tour.HIGHLIGHT_PADDING : 0)}px`;
    this.fadeElement.style.top = `${targetBoundingRect.top - ((step.selector ? Tour.HIGHLIGHT_PADDING : 0) / 2)}px`;
    this.fadeElement.style.left = `${targetBoundingRect.left - ((step.selector ? Tour.HIGHLIGHT_PADDING : 0) / 2)}px`;
    document.body.appendChild(this.fadeElement);

    // Add Overlay to block input
    this.overlayElement = document.createElement("div");
    this.overlayElement.classList.add("tour-overlay");
    document.body.appendChild(this.overlayElement);

    // Activate Listeners
    const buttons = step.selector ? game.tooltip.tooltip.querySelectorAll(".step-button")
      : this.targetElement.querySelectorAll(".step-button");
    for ( let button of buttons ) {
      button.addEventListener("click", event => this._onButtonClick(event, buttons));
    }
  }

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

  /**
   * Handle Tour Button clicks
   * @param {Event} event   A click event
   * @param {HTMLElement[]} buttons   The step buttons
   * @private
   */
  _onButtonClick(event, buttons) {
    event.preventDefault();

    // Disable all the buttons to prevent double-clicks
    for ( let button of buttons ) {
      button.classList.add("disabled");
    }

    // Handle action
    const action = event.currentTarget.dataset.action;
    switch ( action ) {
      case "exit": return this.exit();
      case "previous": return this.previous();
      case "next": return this.next();
      default: throw new Error(`Unexpected Tour button action - ${action}`);
    }
  }

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

  /**
   * Saves the current progress of the Tour to a world setting
   * @private
   */
  _saveProgress() {
    let progress = game.settings.get("core", "tourProgress");
    if ( !(this.namespace in progress) ) progress[this.namespace] = {};
    progress[this.namespace][this.id] = this.#stepIndex;
    game.settings.set("core", "tourProgress", progress);
  }

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

  /**
   * Returns the User's current progress of this Tour
   * @returns {null|number}
   * @private
   */
  _loadProgress() {
    let progress = game.settings.get("core", "tourProgress");
    return progress?.[this.namespace]?.[this.id] ?? -1;
  }

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

  /**
   * Reloads the Tour's current step from the saved progress
   * @internal
   */
  _reloadProgress() {
    this.#stepIndex = this._loadProgress();
  }
}

/**
 * A singleton Tour Collection class responsible for registering and activating Tours, accessible as game.tours
 * @see {Game#tours}
 * @extends Map
 */
class Tours extends foundry.utils.Collection {

  constructor() {
    super();
    if ( game.tours ) throw new Error("You can only have one TourManager instance");
  }

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

  /**
   * Register a new Tour
   * @param {string} namespace          The namespace of the Tour
   * @param {string} id                 The machine-readable id of the Tour
   * @param {Tour} tour                 The constructed Tour
   * @returns {void}
   */
  register(namespace, id, tour) {
    if ( !namespace || !id ) throw new Error("You must specify both the namespace and id portion of the Tour");
    if ( !(tour instanceof Tour) ) throw new Error("You must pass in a Tour instance");

    // Set the namespace and id of the tour if not already set.
    if ( id && !tour.id ) tour.id = id;
    if ( namespace && !tour.namespace ) tour.namespace = namespace;
    tour._reloadProgress();

    // Register the Tour if it is not already registered, ensuring the key matches the config
    if ( this.has(tour.key) ) throw new Error(`Tour "${key}" has already been registered`);
    this.set(`${namespace}.${id}`, tour);
  }

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

  /**
   * @inheritDoc
   * @override
   */
  set(key, tour) {
    if ( key !== tour.key ) throw new Error(`The key "${key}" does not match what has been configured for the Tour`);
    return super.set(key, tour);
  }
}


/**
 * Export data content to be saved to a local file
 * @param {string} data       Data content converted to a string
 * @param {string} type       The type of
 * @param {string} filename   The filename of the resulting download
 */
function saveDataToFile(data, type, filename) {
  const blob = new Blob([data], {type: type});

  // Create an element to trigger the download
  let a = document.createElement('a');
  a.href = window.URL.createObjectURL(blob);
  a.download = filename;

  // Dispatch a click event to the element
  a.dispatchEvent(new MouseEvent("click", {bubbles: true, cancelable: true, view: window}));
  setTimeout(() => window.URL.revokeObjectURL(a.href), 100);
}


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


/**
 * Read text data from a user provided File object
 * @param {File} file           A File object
 * @return {Promise.<String>}   A Promise which resolves to the loaded text data
 */
function readTextFromFile(file) {
  const reader = new FileReader();
  return new Promise((resolve, reject) => {
    reader.onload = ev => {
      resolve(reader.result);
    };
    reader.onerror = ev => {
      reader.abort();
      reject();
    };
    reader.readAsText(file);
  });
}

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

/**
 * Retrieve a Document by its Universally Unique Identifier (uuid).
 * @param {string} uuid                      The uuid of the Document to retrieve.
 * @param {object} [options]                 Options to configure how a UUID is resolved.
 * @param {Document} [options.relative]      A Document to resolve relative UUIDs against.
 * @param {boolean} [options.invalid=false]  Allow retrieving an invalid Document.
 * @returns {Promise<Document|null>}         Returns the Document if it could be found, otherwise null.
 */
async function fromUuid(uuid, options={}) {
  if ( !uuid ) return null;
  /** @deprecated since v11 */
  if ( foundry.utils.getType(options) !== "Object" ) {
    foundry.utils.logCompatibilityWarning("Passing a relative document as the second parameter to fromUuid is "
      + "deprecated. Please pass it within an options object instead.", {since: 11, until: 13});
    options = {relative: options};
  }
  const {relative, invalid=false} = options;
  let {type, id, primaryId, collection, embedded, doc} = foundry.utils.parseUuid(uuid, {relative});
  if ( collection instanceof CompendiumCollection ) {
    if ( type === "Folder" ) return collection.folders.get(id);
    doc = await collection.getDocument(primaryId ?? id);
  }
  else doc = doc ?? collection?.get(primaryId ?? id, {invalid});
  if ( embedded.length ) doc = _resolveEmbedded(doc, embedded, {invalid});
  return doc || null;
}

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

/**
 * Retrieve a Document by its Universally Unique Identifier (uuid) synchronously. If the uuid resolves to a compendium
 * document, that document's index entry will be returned instead.
 * @param {string} uuid                      The uuid of the Document to retrieve.
 * @param {object} [options]                 Options to configure how a UUID is resolved.
 * @param {Document} [options.relative]      A Document to resolve relative UUIDs against.
 * @param {boolean} [options.invalid=false]  Allow retrieving an invalid Document.
 * @param {boolean} [options.strict=true]    Throw an error if the UUID cannot be resolved synchronously.
 * @returns {Document|object|null}           The Document or its index entry if it resides in a Compendium, otherwise
 *                                           null.
 * @throws If the uuid resolves to a Document that cannot be retrieved synchronously, and the strict option is true.
 */
function fromUuidSync(uuid, options={}) {
  if ( !uuid ) return null;
  /** @deprecated since v11 */
  if ( foundry.utils.getType(options) !== "Object" ) {
    foundry.utils.logCompatibilityWarning("Passing a relative document as the second parameter to fromUuidSync is "
      + "deprecated. Please pass it within an options object instead.", {since: 11, until: 13});
    options = {relative: options};
  }
  const {relative, invalid=false, strict=true} = options;
  let {type, id, primaryId, collection, embedded, doc} = foundry.utils.parseUuid(uuid, {relative});
  if ( (collection instanceof CompendiumCollection) && embedded.length ) {
    if ( !strict ) return null;
    throw new Error(
      `fromUuidSync was invoked on UUID '${uuid}' which references an Embedded Document and cannot be retrieved `
      + "synchronously.");
  }

  const baseId = primaryId ?? id;
  if ( collection instanceof CompendiumCollection ) {
    if ( type === "Folder" ) return collection.folders.get(id);
    doc = doc ?? collection.get(baseId, {invalid}) ?? collection.index.get(baseId);
    if ( doc ) doc.pack = collection.collection;
  }
  else {
    doc = doc ?? collection?.get(baseId, {invalid});
    if ( embedded.length ) doc = _resolveEmbedded(doc, embedded, {invalid});
  }
  return doc || null;
}

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

/**
 * Resolve a series of embedded document UUID parts against a parent Document.
 * @param {Document} parent                  The parent Document.
 * @param {string[]} parts                   A series of Embedded Document UUID parts.
 * @param {object} [options]                 Additional options to configure Embedded Document resolution.
 * @param {boolean} [options.invalid=false]  Allow retrieving an invalid Embedded Document.
 * @returns {Document}                       The resolved Embedded Document.
 * @private
 */
function _resolveEmbedded(parent, parts, {invalid=false}={}) {
  let doc = parent;
  while ( doc && (parts.length > 1) ) {
    const [embeddedName, embeddedId] = parts.splice(0, 2);
    doc = doc.getEmbeddedDocument(embeddedName, embeddedId, {invalid});
  }
  return doc;
}

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

/**
 * Return a reference to the Document class implementation which is configured for use.
 * @param {string} documentName                 The canonical Document name, for example "Actor"
 * @returns {typeof foundry.abstract.Document}  The configured Document class implementation
 */
function getDocumentClass(documentName) {
  return CONFIG[documentName]?.documentClass;
}

/**
 * A helper class to provide common functionality for working with HTML5 video objects
 * A singleton instance of this class is available as ``game.video``
 */
class VideoHelper {
  constructor() {
    if ( game.video instanceof this.constructor ) {
      throw new Error("You may not re-initialize the singleton VideoHelper. Use game.video instead.");
    }

    /**
     * A user gesture must be registered before video playback can begin.
     * This Set records the video elements which await such a gesture.
     * @type {Set}
     */
    this.pending = new Set();

    /**
     * A mapping of base64 video thumbnail images
     * @type {Map<string,string>}
     */
    this.thumbs = new Map();

    /**
     * A flag for whether video playback is currently locked by awaiting a user gesture
     * @type {boolean}
     */
    this.locked = true;
  }

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

  /**
   * Store a Promise while the YouTube API is initializing.
   * @type {Promise}
   */
  #youTubeReady;

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

  /**
   * The YouTube URL regex.
   * @type {RegExp}
   */
  #youTubeRegex = /^https:\/\/(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=([^&]+)|(?:embed\/)?([^?]+))/;

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Return the HTML element which provides the source for a loaded texture.
   * @param {PIXI.Sprite|SpriteMesh} mesh                       The rendered mesh
   * @returns {HTMLImageElement|HTMLVideoElement|null}          The source HTML element
   */
  getSourceElement(mesh) {
    if ( !mesh.texture.valid ) return null;
    return mesh.texture.baseTexture.resource.source;
  }

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

  /**
   * Get the video element source corresponding to a Sprite or SpriteMesh.
   * @param {PIXI.Sprite|SpriteMesh|PIXI.Texture} object        The PIXI source
   * @returns {HTMLVideoElement|null}                           The source video element or null
   */
  getVideoSource(object) {
    if ( !object ) return null;
    const texture = object.texture || object;
    if ( !texture.valid ) return null;
    const source = texture.baseTexture.resource.source;
    return source?.tagName === "VIDEO" ? source : null;
  }

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

  /**
   * Clone a video texture so that it can be played independently of the original base texture.
   * @param {HTMLVideoElement} source     The video element source
   * @returns {Promise<PIXI.Texture>}     An unlinked PIXI.Texture which can be played independently
   */
  async cloneTexture(source) {
    const clone = source.cloneNode(true);
    const resource = new PIXI.VideoResource(clone, {autoPlay: false});
    resource.internal = true;
    await resource.load();
    return new PIXI.Texture(new PIXI.BaseTexture(resource, {
      alphaMode: await PIXI.utils.detectVideoAlphaMode()
    }));
  }

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

  /**
   * Check if a source has a video extension.
   * @param {string} src          The source.
   * @returns {boolean}           If the source has a video extension or not.
   */
  static hasVideoExtension(src) {
    let rgx = new RegExp(`(\\.${Object.keys(CONST.VIDEO_FILE_EXTENSIONS).join("|\\.")})(\\?.*)?`, "i");
    return rgx.test(src);
  }

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

  /**
   * Play a single video source
   * If playback is not yet enabled, add the video to the pending queue
   * @param {HTMLElement} video     The VIDEO element to play
   * @param {object} [options={}]   Additional options for modifying video playback
   * @param {boolean} [options.playing] Should the video be playing? Otherwise, it will be paused
   * @param {boolean} [options.loop]    Should the video loop?
   * @param {number} [options.offset]   A specific timestamp between 0 and the video duration to begin playback
   * @param {number} [options.volume]   Desired volume level of the video's audio channel (if any)
   */
  async play(video, {playing=true, loop=true, offset, volume}={}) {

    // Video offset time and looping
    video.loop = loop;
    offset ??= video.currentTime;

    // Playback volume and muted state
    if ( volume !== undefined ) video.volume = volume;

    // Pause playback
    if ( !playing ) return video.pause();

    // Wait for user gesture
    if ( this.locked ) return this.pending.add([video, offset]);

    // Begin playback
    video.currentTime = Math.clamp(offset, 0, video.duration);
    return video.play();
  }

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

  /**
   * Stop a single video source
   * @param {HTMLElement} video   The VIDEO element to stop
   */
  stop(video) {
    video.pause();
    video.currentTime = 0;
  }

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

  /**
   * Register an event listener to await the first mousemove gesture and begin playback once observed
   * A user interaction must involve a mouse click or keypress.
   * Listen for any of these events, and handle the first observed gesture.
   */
  awaitFirstGesture() {
    if ( !this.locked ) return;
    const interactions = ["contextmenu", "auxclick", "pointerdown", "pointerup", "keydown"];
    interactions.forEach(event => document.addEventListener(event, this._onFirstGesture.bind(this), {once: true}));
  }

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

  /**
   * Handle the first observed user gesture
   * We need a slight delay because unfortunately Chrome is stupid and doesn't always acknowledge the gesture fast enough.
   * @param {Event} event   The mouse-move event which enables playback
   */
  _onFirstGesture(event) {
    this.locked = false;
    if ( !this.pending.size ) return;
    console.log(`${vtt} | Activating pending video playback with user gesture.`);
    for ( const [video, offset] of Array.from(this.pending) ) {
      this.play(video, {offset, loop: video.loop});
    }
    this.pending.clear();
  }

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

  /**
   * Create and cache a static thumbnail to use for the video.
   * The thumbnail is cached using the video file path or URL.
   * @param {string} src        The source video URL
   * @param {object} options    Thumbnail creation options, including width and height
   * @returns {Promise<string>}  The created and cached base64 thumbnail image, or a placeholder image if the canvas is
   *                            disabled and no thumbnail can be generated.
   */
  async createThumbnail(src, options) {
    if ( game.settings.get("core", "noCanvas") ) return "icons/svg/video.svg";
    const t = await ImageHelper.createThumbnail(src, options);
    this.thumbs.set(src, t.thumb);
    return t.thumb;
  }

  /* -------------------------------------------- */
  /*  YouTube API                                 */
  /* -------------------------------------------- */

  /**
   * Lazily-load the YouTube API and retrieve a Player instance for a given iframe.
   * @param {string} id      The iframe ID.
   * @param {object} config  A player config object. See {@link https://developers.google.com/youtube/iframe_api_reference} for reference.
   * @returns {Promise<YT.Player>}
   */
  async getYouTubePlayer(id, config={}) {
    this.#youTubeReady ??= this.#injectYouTubeAPI();
    await this.#youTubeReady;
    return new Promise(resolve => new YT.Player(id, foundry.utils.mergeObject(config, {
      events: {
        onReady: event => resolve(event.target)
      }
    })));
  }

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

  /**
   * Retrieve a YouTube video ID from a URL.
   * @param {string} url  The URL.
   * @returns {string}
   */
  getYouTubeId(url) {
    const [, id1, id2] = url?.match(this.#youTubeRegex) || [];
    return id1 || id2 || "";
  }

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

  /**
   * Take a URL to a YouTube video and convert it into a URL suitable for embedding in a YouTube iframe.
   * @param {string} url   The URL to convert.
   * @param {object} vars  YouTube player parameters.
   * @returns {string}     The YouTube embed URL.
   */
  getYouTubeEmbedURL(url, vars={}) {
    const videoId = this.getYouTubeId(url);
    if ( !videoId ) return "";
    const embed = new URL(`https://www.youtube.com/embed/${videoId}`);
    embed.searchParams.append("enablejsapi", "1");
    Object.entries(vars).forEach(([k, v]) => embed.searchParams.append(k, v));
    // To loop a video with iframe parameters, we must additionally supply the playlist parameter that points to the
    // same video: https://developers.google.com/youtube/player_parameters#Parameters
    if ( vars.loop ) embed.searchParams.append("playlist", videoId);
    return embed.href;
  }

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

  /**
   * Test a URL to see if it points to a YouTube video.
   * @param {string} url  The URL to test.
   * @returns {boolean}
   */
  isYouTubeURL(url="") {
    return this.#youTubeRegex.test(url);
  }

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

  /**
   * Inject the YouTube API into the page.
   * @returns {Promise}  A Promise that resolves when the API has initialized.
   */
  #injectYouTubeAPI() {
    const script = document.createElement("script");
    script.src = "https://www.youtube.com/iframe_api";
    document.head.appendChild(script);
    return new Promise(resolve => {
      window.onYouTubeIframeAPIReady = () => {
        delete window.onYouTubeIframeAPIReady;
        resolve();
      };
    });
  }
}

/**
 * @typedef {Record<string, any>} WorkerTask
 * @property {number} [taskId]          An incrementing task ID used to reference task progress
 * @property {WorkerManager.WORKER_TASK_ACTIONS} action  The task action being performed, from WorkerManager.WORKER_TASK_ACTIONS
 */

/**
 * An asynchronous web Worker which can load user-defined functions and await execution using Promises.
 * @param {string} name                 The worker name to be initialized
 * @param {object} [options={}]         Worker initialization options
 * @param {boolean} [options.debug=false]           Should the worker run in debug mode?
 * @param {boolean} [options.loadPrimitives=false]  Should the worker automatically load the primitives library?
 * @param {string[]} [options.scripts]              Should the worker operates in script modes? Optional scripts.
 */
class AsyncWorker extends Worker {
  constructor(name, {debug=false, loadPrimitives=false, scripts}={}) {
    super(AsyncWorker.WORKER_HARNESS_JS);
    this.name = name;
    this.addEventListener("message", this.#onMessage.bind(this));
    this.addEventListener("error", this.#onError.bind(this));

    this.#ready = this.#dispatchTask({
      action: WorkerManager.WORKER_TASK_ACTIONS.INIT,
      workerName: name,
      debug,
      loadPrimitives,
      scripts
    });
  }

  /**
   * A path reference to the JavaScript file which provides companion worker-side functionality.
   * @type {string}
   */
  static WORKER_HARNESS_JS = "scripts/worker.js";

  /**
   * A queue of active tasks that this Worker is executing.
   * @type {Map<number, {resolve: (result: any) => void, reject: (error: Error) => void}>}
   */
  #tasks = new Map();

  /**
   * An auto-incrementing task index.
   * @type {number}
   */
  #taskIndex = 0;

  /**
   * A Promise which resolves once the Worker is ready to accept tasks
   * @type {Promise}
   */
  get ready() {
    return this.#ready;
  }

  #ready;

  /* -------------------------------------------- */
  /*  Task Management                             */
  /* -------------------------------------------- */

  /**
   * Load a function onto a given Worker.
   * The function must be a pure function with no external dependencies or requirements on global scope.
   * @param {string} functionName   The name of the function to load
   * @param {Function} functionRef  A reference to the function that should be loaded
   * @returns {Promise<unknown>}    A Promise which resolves once the Worker has loaded the function.
   */
  async loadFunction(functionName, functionRef) {
    return this.#dispatchTask({
      action: WorkerManager.WORKER_TASK_ACTIONS.LOAD,
      functionName,
      functionBody: functionRef.toString()
    });
  }

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

  /**
   * Execute a task on a specific Worker.
   * @param {string} functionName   The named function to execute on the worker. This function must first have been
   *                                loaded.
   * @param {Array<*>} [args]       An array of parameters with which to call the requested function
   * @param {Array<*>} [transfer]   An array of transferable objects which are transferred to the worker thread.
   *                                See https://developer.mozilla.org/en-US/docs/Glossary/Transferable_objects
   * @returns {Promise<unknown>}    A Promise which resolves with the returned result of the function once complete.
   */
  async executeFunction(functionName, args=[], transfer=[]) {
    const action = WorkerManager.WORKER_TASK_ACTIONS.EXECUTE;
    return this.#dispatchTask({action, functionName, args}, transfer);
  }

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

  /**
   * Dispatch a task to a named Worker, awaiting confirmation of the result.
   * @param {WorkerTask} taskData   Data to dispatch to the Worker as part of the task.
   * @param {Array<*>} transfer     An array of transferable objects which are transferred to the worker thread.
   * @returns {Promise}             A Promise which wraps the task transaction.
   */
  async #dispatchTask(taskData={}, transfer=[]) {
    const taskId = taskData.taskId = this.#taskIndex++;
    return new Promise((resolve, reject) => {
      this.#tasks.set(taskId, {resolve, reject});
      this.postMessage(taskData, transfer);
    });
  }

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

  /**
   * Handle messages emitted by the Worker thread.
   * @param {MessageEvent} event      The dispatched message event
   */
  #onMessage(event) {
    const response = event.data;
    const task = this.#tasks.get(response.taskId);
    if ( !task ) return;
    this.#tasks.delete(response.taskId);
    if ( response.error ) return task.reject(response.error);
    return task.resolve(response.result);
  }

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

  /**
   * Handle errors emitted by the Worker thread.
   * @param {ErrorEvent} error        The dispatched error event
   */
  #onError(error) {
    error.message = `An error occurred in Worker ${this.name}: ${error.message}`;
    console.error(error);
  }
}

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

/**
 * A client-side class responsible for managing a set of web workers.
 * This interface is accessed as a singleton instance via game.workers.
 * @see Game#workers
 */
class WorkerManager extends Map {
  constructor() {
    if ( game.workers instanceof WorkerManager ) {
      throw new Error("The singleton WorkerManager instance has already been constructed as Game#workers");
    }
    super();
  }

  /**
   * Supported worker task actions
   * @enum {string}
   */
  static WORKER_TASK_ACTIONS = Object.freeze({
    INIT: "init",
    LOAD: "load",
    EXECUTE: "execute"
  });

  /* -------------------------------------------- */
  /*  Worker Management                           */
  /* -------------------------------------------- */

  /**
   * Create a new named Worker.
   * @param {string} name                 The named Worker to create
   * @param {object} [config={}]          Worker configuration parameters passed to the AsyncWorker constructor
   * @returns {Promise<AsyncWorker>}      The created AsyncWorker which is ready to accept tasks
   */
  async createWorker(name, config={}) {
    if (this.has(name)) {
      throw new Error(`A Worker already exists with the name "${name}"`);
    }
    const worker = new AsyncWorker(name, config);
    this.set(name, worker);
    await worker.ready;
    return worker;
  }

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

  /**
   * Retire a current Worker, terminating it immediately.
   * @see Worker#terminate
   * @param {string} name           The named worker to terminate
   */
  retireWorker(name) {
    const worker = this.get(name);
    if ( !worker ) return;
    worker.terminate();
    this.delete(name);
  }

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

  /**
   * @deprecated since 11
   * @ignore
   */
  getWorker(name) {
    foundry.utils.logCompatibilityWarning("WorkerManager#getWorker is deprecated in favor of WorkerManager#get",
      {since: 11, until: 13});
    const w = this.get(name);
    if ( !w ) throw new Error(`No worker with name ${name} currently exists!`);
    return w;
  }
}

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

/**
 * A namespace containing the user interface applications which are defined throughout the Foundry VTT ecosystem.
 * @namespace applications
 */

let _appId = globalThis._appId = 0;
let _maxZ = Number(getComputedStyle(document.body).getPropertyValue("--z-index-window") ?? 100);

const MIN_WINDOW_WIDTH = 200;
const MIN_WINDOW_HEIGHT = 50;

/**
 * @typedef {object} ApplicationOptions
 * @property {string|null} [baseApplication]  A named "base application" which generates an additional hook
 * @property {number|null} [width]         The default pixel width for the rendered HTML
 * @property {number|string|null} [height]  The default pixel height for the rendered HTML
 * @property {number|null} [top]           The default offset-top position for the rendered HTML
 * @property {number|null} [left]          The default offset-left position for the rendered HTML
 * @property {number|null} [scale]         A transformation scale for the rendered HTML
 * @property {boolean} [popOut]            Whether to display the application as a pop-out container
 * @property {boolean} [minimizable]       Whether the rendered application can be minimized (popOut only)
 * @property {boolean} [resizable]         Whether the rendered application can be drag-resized (popOut only)
 * @property {string} [id]                 The default CSS id to assign to the rendered HTML
 * @property {string[]} [classes]          An array of CSS string classes to apply to the rendered HTML
 * @property {string} [title]              A default window title string (popOut only)
 * @property {string|null} [template]      The default HTML template path to render for this Application
 * @property {string[]} [scrollY]          A list of unique CSS selectors which target containers that should have their
 *                                         vertical scroll positions preserved during a re-render.
 * @property {TabsConfiguration[]} [tabs]  An array of tabbed container configurations which should be enabled for the
 *                                         application.
 * @property {DragDropConfiguration[]} dragDrop  An array of CSS selectors for configuring the application's
 *                                               {@link DragDrop} behaviour.
 * @property {SearchFilterConfiguration[]} filters An array of {@link SearchFilter} configuration objects.
 */

/**
 * The standard application window that is rendered for a large variety of UI elements in Foundry VTT.
 * @abstract
 * @param {ApplicationOptions} [options]  Configuration options which control how the application is rendered.
 *                                        Application subclasses may add additional supported options, but these base
 *                                        configurations are supported for all Applications. The values passed to the
 *                                        constructor are combined with the defaultOptions defined at the class level.
 */
class Application {
  constructor(options={}) {

    /**
     * The options provided to this application upon initialization
     * @type {object}
     */
    this.options = foundry.utils.mergeObject(this.constructor.defaultOptions, options, {
      insertKeys: true,
      insertValues: true,
      overwrite: true,
      inplace: false
    });

    /**
     * An internal reference to the HTML element this application renders
     * @type {jQuery}
     */
    this._element = null;

    /**
     * Track the current position and dimensions of the Application UI
     * @type {object}
     */
    this.position = {
      width: this.options.width,
      height: this.options.height,
      left: this.options.left,
      top: this.options.top,
      scale: this.options.scale,
      zIndex: 0
    };

    /**
     * DragDrop workflow handlers which are active for this Application
     * @type {DragDrop[]}
     */
    this._dragDrop = this._createDragDropHandlers();

    /**
     * Tab navigation handlers which are active for this Application
     * @type {Tabs[]}
     */
    this._tabs = this._createTabHandlers();

    /**
     * SearchFilter handlers which are active for this Application
     * @type {SearchFilter[]}
     */
    this._searchFilters = this._createSearchFilters();

    /**
     * Track whether the Application is currently minimized
     * @type {boolean|null}
     */
    this._minimized = false;

    /**
     * The current render state of the Application
     * @see {Application.RENDER_STATES}
     * @type {number}
     * @protected
     */
    this._state = Application.RENDER_STATES.NONE;

    /**
     * The prior render state of this Application.
     * This allows for rendering logic to understand if the application is being rendered for the first time.
     * @see {Application.RENDER_STATES}
     * @type {number}
     * @protected
     */
    this._priorState = this._state;

    /**
     * Track the most recent scroll positions for any vertically scrolling containers
     * @type {object | null}
     */
    this._scrollPositions = null;
  }

  /**
   * The application ID is a unique incrementing integer which is used to identify every application window
   * drawn by the VTT
   * @type {number}
   */
  appId;

  /**
   * The sequence of rendering states that track the Application life-cycle.
   * @enum {number}
   */
  static RENDER_STATES = Object.freeze({
    ERROR: -3,
    CLOSING: -2,
    CLOSED: -1,
    NONE: 0,
    RENDERING: 1,
    RENDERED: 2
  });

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

  /**
   * Create drag-and-drop workflow handlers for this Application
   * @returns {DragDrop[]}     An array of DragDrop handlers
   * @private
   */
  _createDragDropHandlers() {
    return this.options.dragDrop.map(d => {
      d.permissions = {
        dragstart: this._canDragStart.bind(this),
        drop: this._canDragDrop.bind(this)
      };
      d.callbacks = {
        dragstart: this._onDragStart.bind(this),
        dragover: this._onDragOver.bind(this),
        drop: this._onDrop.bind(this)
      };
      return new DragDrop(d);
    });
  }

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

  /**
   * Create tabbed navigation handlers for this Application
   * @returns {Tabs[]}     An array of Tabs handlers
   * @private
   */
  _createTabHandlers() {
    return this.options.tabs.map(t => {
      t.callback = this._onChangeTab.bind(this);
      return new Tabs(t);
    });
  }

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

  /**
   * Create search filter handlers for this Application
   * @returns {SearchFilter[]}  An array of SearchFilter handlers
   * @private
   */
  _createSearchFilters() {
    return this.options.filters.map(f => {
      f.callback = this._onSearchFilter.bind(this);
      return new SearchFilter(f);
    });
  }

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

  /**
   * Assign the default options configuration which is used by this Application class. The options and values defined
   * in this object are merged with any provided option values which are passed to the constructor upon initialization.
   * Application subclasses may include additional options which are specific to their usage.
   * @returns {ApplicationOptions}
   */
  static get defaultOptions() {
    return {
      baseApplication: null,
      width: null,
      height: null,
      top: null,
      left: null,
      scale: null,
      popOut: true,
      minimizable: true,
      resizable: false,
      id: "",
      classes: [],
      dragDrop: [],
      tabs: [],
      filters: [],
      title: "",
      template: null,
      scrollY: []
    };
  }

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

  /**
   * Return the CSS application ID which uniquely references this UI element
   * @type {string}
   */
  get id() {
    return this.options.id ? this.options.id : `app-${this.appId}`;
  }

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

  /**
   * Return the active application element, if it currently exists in the DOM
   * @type {jQuery}
   */
  get element() {
    if ( this._element ) return this._element;
    let selector = `#${this.id}`;
    return $(selector);
  }

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

  /**
   * The path to the HTML template file which should be used to render the inner content of the app
   * @type {string}
   */
  get template() {
    return this.options.template;
  }

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

  /**
   * Control the rendering style of the application. If popOut is true, the application is rendered in its own
   * wrapper window, otherwise only the inner app content is rendered
   * @type {boolean}
   */
  get popOut() {
    return this.options.popOut ?? true;
  }

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

  /**
   * Return a flag for whether the Application instance is currently rendered
   * @type {boolean}
   */
  get rendered() {
    return this._state === Application.RENDER_STATES.RENDERED;
  }

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

  /**
   * Whether the Application is currently closing.
   * @type {boolean}
   */
  get closing() {
    return this._state === Application.RENDER_STATES.CLOSING;
  }

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

  /**
   * An Application window should define its own title definition logic which may be dynamic depending on its data
   * @type {string}
   */
  get title() {
    return game.i18n.localize(this.options.title);
  }

  /* -------------------------------------------- */
  /* Application rendering
  /* -------------------------------------------- */

  /**
   * An application should define the data object used to render its template.
   * This function may either return an Object directly, or a Promise which resolves to an Object
   * If undefined, the default implementation will return an empty object allowing only for rendering of static HTML
   * @param {object} options
   * @returns {object|Promise<object>}
   */
  getData(options={}) {
    return {};
  }

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

  /**
   * Render the Application by evaluating it's HTML template against the object of data provided by the getData method
   * If the Application is rendered as a pop-out window, wrap the contained HTML in an outer frame with window controls
   *
   * @param {boolean} force   Add the rendered application to the DOM if it is not already present. If false, the
   *                          Application will only be re-rendered if it is already present.
   * @param {object} options  Additional rendering options which are applied to customize the way that the Application
   *                          is rendered in the DOM.
   *
   * @param {number} [options.left]           The left positioning attribute
   * @param {number} [options.top]            The top positioning attribute
   * @param {number} [options.width]          The rendered width
   * @param {number} [options.height]         The rendered height
   * @param {number} [options.scale]          The rendered transformation scale
   * @param {boolean} [options.focus=false]   Apply focus to the application, maximizing it and bringing it to the top
   *                                          of the vertical stack.
   * @param {string} [options.renderContext]  A context-providing string which suggests what event triggered the render
   * @param {object} [options.renderData]     The data change which motivated the render request
   *
   * @returns {Application}                 The rendered Application instance
   *
   */
  render(force=false, options={}) {
    this._render(force, options).catch(err => {
      this._state = Application.RENDER_STATES.ERROR;
      Hooks.onError("Application#render", err, {
        msg: `An error occurred while rendering ${this.constructor.name} ${this.appId}`,
        log: "error",
        ...options
      });
    });
    return this;
  }

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

  /**
   * An asynchronous inner function which handles the rendering of the Application
   * @fires renderApplication
   * @param {boolean} force     Render and display the application even if it is not currently displayed.
   * @param {object} options    Additional options which update the current values of the Application#options object
   * @returns {Promise<void>}   A Promise that resolves to the Application once rendering is complete
   * @protected
   */
  async _render(force=false, options={}) {

    // Do not render under certain conditions
    const states = Application.RENDER_STATES;
    this._priorState = this._state;
    if ( [states.CLOSING, states.RENDERING].includes(this._state) ) return;

    // Applications which are not currently rendered must be forced
    if ( !force && (this._state <= states.NONE) ) return;

    // Begin rendering the application
    if ( [states.NONE, states.CLOSED, states.ERROR].includes(this._state) ) {
      console.log(`${vtt} | Rendering ${this.constructor.name}`);
    }
    this._state = states.RENDERING;

    // Merge provided options with those supported by the Application class
    foundry.utils.mergeObject(this.options, options, { insertKeys: false });
    options.focus ??= force;

    // Get the existing HTML element and application data used for rendering
    const element = this.element;
    this.appId = element.data("appid") ?? ++_appId;
    if ( this.popOut ) ui.windows[this.appId] = this;
    const data = await this.getData(this.options);

    // Store scroll positions
    if ( element.length && this.options.scrollY ) this._saveScrollPositions(element);

    // Render the inner content
    const inner = await this._renderInner(data);
    let html = inner;

    // If the application already exists in the DOM, replace the inner content
    if ( element.length ) this._replaceHTML(element, html);

    // Otherwise render a new app
    else {

      // Wrap a popOut application in an outer frame
      if ( this.popOut ) {
        html = await this._renderOuter();
        html.find(".window-content").append(inner);
      }

      // Add the HTML to the DOM and record the element
      this._injectHTML(html);
    }

    if ( !this.popOut && this.options.resizable ) new Draggable(this, html, false, this.options.resizable);

    // Activate event listeners on the inner HTML
    this._activateCoreListeners(inner);
    this.activateListeners(inner);

    // Set the application position (if it's not currently minimized)
    if ( !this._minimized ) {
      foundry.utils.mergeObject(this.position, options, {insertKeys: false});
      this.setPosition(this.position);
    }

    // Apply focus to the application, maximizing it and bringing it to the top
    if ( this.popOut && (options.focus === true) ) this.maximize().then(() => this.bringToTop());

    // Dispatch Hooks for rendering the base and subclass applications
    this._callHooks("render", html, data);

    // Restore prior scroll positions
    if ( this.options.scrollY ) this._restoreScrollPositions(html);
    this._state = states.RENDERED;
  }

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

  /**
   * Return the inheritance chain for this Application class up to (and including) it's base Application class.
   * @returns {Function[]}
   * @private
   */
  static _getInheritanceChain() {
    const parents = foundry.utils.getParentClasses(this);
    const base = this.defaultOptions.baseApplication;
    const chain = [this];
    for ( let cls of parents ) {
      chain.push(cls);
      if ( cls.name === base ) break;
    }
    return chain;
  }

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

  /**
   * Call all hooks for all applications in the inheritance chain.
   * @param {string | (className: string) => string} hookName   The hook being triggered, which formatted
   *                                                            with the Application class name
   * @param {...*} hookArgs                                     The arguments passed to the hook calls
   * @protected
   * @internal
   */
  _callHooks(hookName, ...hookArgs) {
    const formatHook = typeof hookName === "string" ? className => `${hookName}${className}` : hookName;
    for ( const cls of this.constructor._getInheritanceChain() ) {
      if ( !cls.name ) continue;
      Hooks.callAll(formatHook(cls.name), this, ...hookArgs);
    }
  }

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

  /**
   * Persist the scroll positions of containers within the app before re-rendering the content
   * @param {jQuery} html           The HTML object being traversed
   * @protected
   */
  _saveScrollPositions(html) {
    const selectors = this.options.scrollY || [];
    this._scrollPositions = selectors.reduce((pos, sel) => {
      const el = html.find(sel);
      pos[sel] = Array.from(el).map(el => el.scrollTop);
      return pos;
    }, {});
  }

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

  /**
   * Restore the scroll positions of containers within the app after re-rendering the content
   * @param {jQuery} html           The HTML object being traversed
   * @protected
   */
  _restoreScrollPositions(html) {
    const selectors = this.options.scrollY || [];
    const positions = this._scrollPositions || {};
    for ( let sel of selectors ) {
      const el = html.find(sel);
      el.each((i, el) => el.scrollTop = positions[sel]?.[i] || 0);
    }
  }

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

  /**
   * Render the outer application wrapper
   * @returns {Promise<jQuery>}   A promise resolving to the constructed jQuery object
   * @protected
   */
  async _renderOuter() {

    // Gather basic application data
    const classes = this.options.classes;
    const windowData = {
      id: this.id,
      classes: classes.join(" "),
      appId: this.appId,
      title: this.title,
      headerButtons: this._getHeaderButtons()
    };

    // Render the template and return the promise
    let html = await renderTemplate("templates/app-window.html", windowData);
    html = $(html);

    // Activate header button click listeners after a slight timeout to prevent immediate interaction
    setTimeout(() => {
      html.find(".header-button").click(event => {
        event.preventDefault();
        const button = windowData.headerButtons.find(b => event.currentTarget.classList.contains(b.class));
        button.onclick(event);
      });
    }, 500);

    // Make the outer window draggable
    const header = html.find("header")[0];
    new Draggable(this, html, header, this.options.resizable);

    // Make the outer window minimizable
    if ( this.options.minimizable ) {
      header.addEventListener("dblclick", this._onToggleMinimize.bind(this));
    }

    // Set the outer frame z-index
    this.position.zIndex = Math.min(++_maxZ, 99999);
    html[0].style.zIndex = this.position.zIndex;
    ui.activeWindow = this;

    // Return the outer frame
    return html;
  }

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

  /**
   * Render the inner application content
   * @param {object} data         The data used to render the inner template
   * @returns {Promise<jQuery>}   A promise resolving to the constructed jQuery object
   * @private
   */
  async _renderInner(data) {
    let html = await renderTemplate(this.template, data);
    if ( html === "" ) throw new Error(`No data was returned from template ${this.template}`);
    return $(html);
  }

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

  /**
   * Customize how inner HTML is replaced when the application is refreshed
   * @param {jQuery} element      The original HTML processed as a jQuery object
   * @param {jQuery} html         New updated HTML as a jQuery object
   * @private
   */
  _replaceHTML(element, html) {
    if ( !element.length ) return;

    // For pop-out windows update the inner content and the window title
    if ( this.popOut ) {
      element.find(".window-content").html(html);
      let t = element.find(".window-title")[0];
      if ( t.hasChildNodes() ) t = t.childNodes[0];
      t.textContent = this.title;
    }

    // For regular applications, replace the whole thing
    else {
      element.replaceWith(html);
      this._element = html;
    }
  }

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

  /**
   * Customize how a new HTML Application is added and first appears in the DOM
   * @param {jQuery} html       The HTML element which is ready to be added to the DOM
   * @private
   */
  _injectHTML(html) {
    $("body").append(html);
    this._element = html;
    html.hide().fadeIn(200);
  }

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

  /**
   * Specify the set of config buttons which should appear in the Application header.
   * Buttons should be returned as an Array of objects.
   * The header buttons which are added to the application can be modified by the getApplicationHeaderButtons hook.
   * @fires getApplicationHeaderButtons
   * @returns {ApplicationHeaderButton[]}
   * @protected
   */
  _getHeaderButtons() {
    const buttons = [
      {
        label: "Close",
        class: "close",
        icon: "fas fa-times",
        onclick: () => this.close()
      }
    ];
    this._callHooks(className => `get${className}HeaderButtons`, buttons);
    return buttons;
  }

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

  /**
   * Create a {@link ContextMenu} for this Application.
   * @param {jQuery} html  The Application's HTML.
   * @private
   */
  _contextMenu(html) {}

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

  /**
   * Activate required listeners which must be enabled on every Application.
   * These are internal interactions which should not be overridden by downstream subclasses.
   * @param {jQuery} html
   * @protected
   */
  _activateCoreListeners(html) {
    const content = this.popOut ? html[0].parentElement : html[0];
    this._tabs.forEach(t => t.bind(content));
    this._dragDrop.forEach(d => d.bind(content));
    this._searchFilters.forEach(f => f.bind(content));
  }

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

  /**
   * After rendering, activate event listeners which provide interactivity for the Application.
   * This is where user-defined Application subclasses should attach their event-handling logic.
   * @param {JQuery} html
   */
  activateListeners(html) {}

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

  /**
   * Change the currently active tab
   * @param {string} tabName      The target tab name to switch to
   * @param {object} options      Options which configure changing the tab
   * @param {string} options.group    A specific named tab group, useful if multiple sets of tabs are present
   * @param {boolean} options.triggerCallback  Whether to trigger tab-change callback functions
   */
  activateTab(tabName, {group, triggerCallback=true}={}) {
    if ( !this._tabs.length ) throw new Error(`${this.constructor.name} does not define any tabs`);
    const tabs = group ? this._tabs.find(t => t.group === group) : this._tabs[0];
    if ( !tabs ) throw new Error(`Tab group "${group}" not found in ${this.constructor.name}`);
    tabs.activate(tabName, {triggerCallback});
  }

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

  /**
   * Handle changes to the active tab in a configured Tabs controller
   * @param {MouseEvent|null} event   A left click event
   * @param {Tabs} tabs               The Tabs controller
   * @param {string} active           The new active tab name
   * @protected
   */
  _onChangeTab(event, tabs, active) {
    this.setPosition();
  }

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

  /**
   * Handle changes to search filtering controllers which are bound to the Application
   * @param {KeyboardEvent} event   The key-up event from keyboard input
   * @param {string} query          The raw string input to the search field
   * @param {RegExp} rgx            The regular expression to test against
   * @param {HTMLElement} html      The HTML element which should be filtered
   * @protected
   */
  _onSearchFilter(event, query, rgx, html) {}

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

  /**
   * Define whether a user is able to begin a dragstart workflow for a given drag selector
   * @param {string} selector       The candidate HTML selector for dragging
   * @returns {boolean}             Can the current user drag this selector?
   * @protected
   */
  _canDragStart(selector) {
    return game.user.isGM;
  }

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

  /**
   * Define whether a user is able to conclude a drag-and-drop workflow for a given drop selector
   * @param {string} selector       The candidate HTML selector for the drop target
   * @returns {boolean}             Can the current user drop on this selector?
   * @protected
   */
  _canDragDrop(selector) {
    return game.user.isGM;
  }

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

  /**
   * Callback actions which occur at the beginning of a drag start workflow.
   * @param {DragEvent} event       The originating DragEvent
   * @protected
   */
  _onDragStart(event) {}

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

  /**
   * Callback actions which occur when a dragged element is over a drop target.
   * @param {DragEvent} event       The originating DragEvent
   * @protected
   */
  _onDragOver(event) {}

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

  /**
   * Callback actions which occur when a dragged element is dropped on a target.
   * @param {DragEvent} event       The originating DragEvent
   * @protected
   */
  _onDrop(event) {}

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Bring the application to the top of the rendering stack
   */
  bringToTop() {
    if ( ui.activeWindow === this ) return;
    const element = this.element[0];
    const z = document.defaultView.getComputedStyle(element).zIndex;
    if ( z < _maxZ ) {
      this.position.zIndex = Math.min(++_maxZ, 99999);
      element.style.zIndex = this.position.zIndex;
      ui.activeWindow = this;
    }
  }

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

  /**
   * Close the application and un-register references to it within UI mappings
   * This function returns a Promise which resolves once the window closing animation concludes
   * @fires closeApplication
   * @param {object} [options={}] Options which affect how the Application is closed
   * @returns {Promise<void>}     A Promise which resolves once the application is closed
   */
  async close(options={}) {
    const states = Application.RENDER_STATES;
    if ( !options.force && ![states.RENDERED, states.ERROR].includes(this._state) ) return;
    this._state = states.CLOSING;

    // Get the element
    let el = this.element;
    if ( !el ) return this._state = states.CLOSED;
    el.css({minHeight: 0});

    // Dispatch Hooks for closing the base and subclass applications
    this._callHooks("close", el);

    // Animate closing the element
    return new Promise(resolve => {
      el.slideUp(200, () => {
        el.remove();

        // Clean up data
        this._element = null;
        delete ui.windows[this.appId];
        this._minimized = false;
        this._scrollPositions = null;
        this._state = states.CLOSED;
        resolve();
      });
    });
  }

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

  /**
   * Minimize the pop-out window, collapsing it to a small tab
   * Take no action for applications which are not of the pop-out variety or apps which are already minimized
   * @returns {Promise<void>}  A Promise which resolves once the minimization action has completed
   */
  async minimize() {
    if ( !this.rendered || !this.popOut || [true, null].includes(this._minimized) ) return;
    this._minimized = null;

    // Get content
    const window = this.element;
    const header = window.find(".window-header");
    const content = window.find(".window-content");
    this._saveScrollPositions(window);

    // Remove minimum width and height styling rules
    window.css({minWidth: 100, minHeight: 30});

    // Slide-up content
    content.slideUp(100);

    // Slide up window height
    return new Promise(resolve => {
      window.animate({height: `${header[0].offsetHeight+1}px`}, 100, () => {
        window.animate({width: MIN_WINDOW_WIDTH}, 100, () => {
          window.addClass("minimized");
          this._minimized = true;
          resolve();
        });
      });
    });
  }

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

  /**
   * Maximize the pop-out window, expanding it to its original size
   * Take no action for applications which are not of the pop-out variety or are already maximized
   * @returns {Promise<void>}    A Promise which resolves once the maximization action has completed
   */
  async maximize() {
    if ( !this.popOut || [false, null].includes(this._minimized) ) return;
    this._minimized = null;

    // Get content
    let window = this.element;
    let content = window.find(".window-content");

    // Expand window
    return new Promise(resolve => {
      window.animate({width: this.position.width, height: this.position.height}, 100, () => {
        content.slideDown(100, () => {
          window.removeClass("minimized");
          this._minimized = false;
          window.css({minWidth: "", minHeight: ""}); // Remove explicit dimensions
          content.css({display: ""});  // Remove explicit "block" display
          this.setPosition(this.position);
          this._restoreScrollPositions(window);
          resolve();
        });
      });
    });
  }

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

  /**
   * Set the application position and store its new location.
   * Returns the updated position object for the application containing the new values.
   * @param {object} position                   Positional data
   * @param {number|null} position.left            The left offset position in pixels
   * @param {number|null} position.top             The top offset position in pixels
   * @param {number|null} position.width           The application width in pixels
   * @param {number|string|null} position.height   The application height in pixels
   * @param {number|null} position.scale           The application scale as a numeric factor where 1.0 is default
   * @returns {{left: number, top: number, width: number, height: number, scale:number}|void}
   */
  setPosition({left, top, width, height, scale}={}) {
    if ( !this.popOut && !this.options.resizable ) return; // Only configure position for popout or resizable apps.
    const el = this.element[0];
    const currentPosition = this.position;
    const pop = this.popOut;
    const styles = window.getComputedStyle(el);
    if ( scale === null ) scale = 1;
    scale = scale ?? currentPosition.scale ?? 1;

    // If Height is "auto" unset current preference
    if ( (height === "auto") || (this.options.height === "auto") ) {
      el.style.height = "";
      height = null;
    }

    // Update width if an explicit value is passed, or if no width value is set on the element
    if ( !el.style.width || width ) {
      const tarW = width || el.offsetWidth;
      const minW = parseInt(styles.minWidth) || (pop ? MIN_WINDOW_WIDTH : 0);
      const maxW = el.style.maxWidth || (window.innerWidth / scale);
      currentPosition.width = width = Math.clamp(tarW, minW, maxW);
      el.style.width = `${width}px`;
      if ( ((width * scale) + currentPosition.left) > window.innerWidth ) left = currentPosition.left;
    }
    width = el.offsetWidth;

    // Update height if an explicit value is passed, or if no height value is set on the element
    if ( !el.style.height || height ) {
      const tarH = height || (el.offsetHeight + 1);
      const minH = parseInt(styles.minHeight) || (pop ? MIN_WINDOW_HEIGHT : 0);
      const maxH = el.style.maxHeight || (window.innerHeight / scale);
      currentPosition.height = height = Math.clamp(tarH, minH, maxH);
      el.style.height = `${height}px`;
      if ( ((height * scale) + currentPosition.top) > window.innerHeight + 1 ) top = currentPosition.top - 1;
    }
    height = el.offsetHeight;

    // Update Left
    if ( (pop && !el.style.left) || Number.isFinite(left) ) {
      const scaledWidth = width * scale;
      const tarL = Number.isFinite(left) ? left : (window.innerWidth - scaledWidth) / 2;
      const maxL = Math.max(window.innerWidth - scaledWidth, 0);
      currentPosition.left = left = Math.clamp(tarL, 0, maxL);
      el.style.left = `${left}px`;
    }

    // Update Top
    if ( (pop && !el.style.top) || Number.isFinite(top) ) {
      const scaledHeight = height * scale;
      const tarT = Number.isFinite(top) ? top : (window.innerHeight - scaledHeight) / 2;
      const maxT = Math.max(window.innerHeight - scaledHeight, 0);
      currentPosition.top = Math.clamp(tarT, 0, maxT);
      el.style.top = `${currentPosition.top}px`;
    }

    // Update Scale
    if ( scale ) {
      currentPosition.scale = Math.max(scale, 0);
      if ( scale === 1 ) el.style.transform = "";
      else el.style.transform = `scale(${scale})`;
    }

    // Return the updated position object
    return currentPosition;
  }

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

  /**
   * Handle application minimization behavior - collapsing content and reducing the size of the header
   * @param {Event} ev
   * @private
   */
  _onToggleMinimize(ev) {
    ev.preventDefault();
    if ( this._minimized ) this.maximize(ev);
    else this.minimize(ev);
  }

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

  /**
   * Additional actions to take when the application window is resized
   * @param {Event} event
   * @private
   */
  _onResize(event) {}

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

  /**
   * Wait for any images present in the Application to load.
   * @returns {Promise<void>}  A Promise that resolves when all images have loaded.
   * @protected
   */
  _waitForImages() {
    return new Promise(resolve => {
      let loaded = 0;
      const images = Array.from(this.element.find("img")).filter(img => !img.complete);
      if ( !images.length ) resolve();
      for ( const img of images ) {
        img.onload = img.onerror = () => {
          loaded++;
          img.onload = img.onerror = null;
          if ( loaded >= images.length ) resolve();
        };
      }
    });
  }
}

/**
 * @typedef {ApplicationOptions} FormApplicationOptions
 * @property {boolean} [closeOnSubmit=true]     Whether to automatically close the application when it's contained
 *                                              form is submitted.
 * @property {boolean} [submitOnChange=false]   Whether to automatically submit the contained HTML form when an input
 *                                              or select element is changed.
 * @property {boolean} [submitOnClose=false]    Whether to automatically submit the contained HTML form when the
 *                                              application window is manually closed.
 * @property {boolean} [editable=true]          Whether the application form is editable - if true, it's fields will
 *                                              be unlocked and the form can be submitted. If false, all form fields
 *                                              will be disabled and the form cannot be submitted.
 * @property {boolean} [sheetConfig=false]      Support configuration of the sheet type used for this application.
 */

/**
 * An abstract pattern for defining an Application responsible for updating some object using an HTML form
 *
 * A few critical assumptions:
 * 1) This application is used to only edit one object at a time
 * 2) The template used contains one (and only one) HTML form as it's outer-most element
 * 3) This abstract layer has no knowledge of what is being updated, so the implementation must define _updateObject
 *
 * @extends {Application}
 * @abstract
 * @interface
 *
 * @param {object} object                     Some object which is the target data structure to be updated by the form.
 * @param {FormApplicationOptions} [options]  Additional options which modify the rendering of the sheet.
 */
class FormApplication extends Application {
  constructor(object={}, options={}) {
    super(options);

    /**
     * The object target which we are using this form to modify
     * @type {*}
     */
    this.object = object;

    /**
     * A convenience reference to the form HTMLElement
     * @type {HTMLElement}
     */
    this.form = null;

    /**
     * Keep track of any mce editors which may be active as part of this form
     * The values of this object are inner-objects with references to the MCE editor and other metadata
     * @type {Record<string, object>}
     */
    this.editors = {};
  }

  /**
   * An array of custom element tag names that should be listened to for changes.
   * @type {string[]}
   * @protected
   */
  static _customElements = Object.values(foundry.applications.elements).reduce((arr, el) => {
    if ( el.tagName ) arr.push(el.tagName);
    return arr;
  }, []);

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

  /**
   * Assign the default options which are supported by the document edit sheet.
   * In addition to the default options object supported by the parent Application class, the Form Application
   * supports the following additional keys and values:
   *
   * @returns {FormApplicationOptions}    The default options for this FormApplication class
   */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["form"],
      closeOnSubmit: true,
      editable: true,
      sheetConfig: false,
      submitOnChange: false,
      submitOnClose: false
    });
  }

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

  /**
   * Is the Form Application currently editable?
   * @type {boolean}
   */
  get isEditable() {
    return this.options.editable;
  }

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

  /**
   * @inheritdoc
   * @returns {object|Promise<object>}
   */
  getData(options={}) {
    return {
      object: this.object,
      options: this.options,
      title: this.title
    };
  }

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

  /** @inheritdoc */
  async _render(force, options) {

    // Identify the focused element
    let focus = this.element.find(":focus");
    focus = focus.length ? focus[0] : null;

    // Render the application and restore focus
    await super._render(force, options);
    if ( focus && focus.name ) {
      const input = this.form?.[focus.name];
      if ( input && (input.focus instanceof Function) ) input.focus();
    }
  }

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

  /** @inheritdoc */
  async _renderInner(...args) {
    const html = await super._renderInner(...args);
    this.form = html.filter((i, el) => el instanceof HTMLFormElement)[0];
    if ( !this.form ) this.form = html.find("form")[0];
    return html;
  }

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

  /** @inheritdoc */
  _activateCoreListeners(html) {
    super._activateCoreListeners(html);
    if ( !this.form ) return;
    if ( !this.isEditable ) {
      return this._disableFields(this.form);
    }
    this.form.onsubmit = this._onSubmit.bind(this);
  }

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

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    if ( !this.isEditable ) return;
    const changeElements = ["input", "select", "textarea"].concat(this.constructor._customElements);
    html.on("change", changeElements.join(","), this._onChangeInput.bind(this));
    html.find(".editor-content[data-edit]").each((i, div) => this._activateEditor(div));
    html.find("button.file-picker").click(this._activateFilePicker.bind(this));
    if ( this._priorState <= this.constructor.RENDER_STATES.NONE ) html.find("[autofocus]")[0]?.focus();
  }

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

  /**
   * If the form is not editable, disable its input fields
   * @param {HTMLElement} form    The form HTML
   * @protected
   */
  _disableFields(form) {
    const inputs = ["INPUT", "SELECT", "TEXTAREA", "BUTTON"];
    for ( let i of inputs ) {
      for ( let el of form.getElementsByTagName(i) ) {
        if ( i === "TEXTAREA" ) el.readOnly = true;
        else el.disabled = true;
      }
    }
  }

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

  /**
   * Handle standard form submission steps
   * @param {Event} event               The submit event which triggered this handler
   * @param {object | null} [updateData]  Additional specific data keys/values which override or extend the contents of
   *                                    the parsed form. This can be used to update other flags or data fields at the
   *                                    same time as processing a form submission to avoid multiple database operations.
   * @param {boolean} [preventClose]    Override the standard behavior of whether to close the form on submit
   * @param {boolean} [preventRender]   Prevent the application from re-rendering as a result of form submission
   * @returns {Promise}                 A promise which resolves to the validated update data
   * @protected
   */
  async _onSubmit(event, {updateData=null, preventClose=false, preventRender=false}={}) {
    event.preventDefault();

    // Prevent double submission
    const states = this.constructor.RENDER_STATES;
    if ( (this._state === states.NONE) || !this.isEditable || this._submitting ) return false;
    this._submitting = true;

    // Process the form data
    const formData = this._getSubmitData(updateData);

    // Handle the form state prior to submission
    let closeForm = this.options.closeOnSubmit && !preventClose;
    const priorState = this._state;
    if ( preventRender ) this._state = states.RENDERING;
    if ( closeForm ) this._state = states.CLOSING;

    // Trigger the object update
    try {
      await this._updateObject(event, formData);
    }
    catch(err) {
      console.error(err);
      closeForm = false;
      this._state = priorState;
    }

    // Restore flags and optionally close the form
    this._submitting = false;
    if ( preventRender ) this._state = priorState;
    if ( closeForm ) await this.close({submit: false, force: true});
    return formData;
  }

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

  /**
   * Get an object of update data used to update the form's target object
   * @param {object} updateData     Additional data that should be merged with the form data
   * @returns {object}               The prepared update data
   * @protected
   */
  _getSubmitData(updateData={}) {
    if ( !this.form ) throw new Error("The FormApplication subclass has no registered form element");
    const fd = new FormDataExtended(this.form, {editors: this.editors});
    let data = fd.object;
    if ( updateData ) data = foundry.utils.flattenObject(foundry.utils.mergeObject(data, updateData));
    return data;
  }

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

  /**
   * Handle changes to an input element, submitting the form if options.submitOnChange is true.
   * Do not preventDefault in this handler as other interactions on the form may also be occurring.
   * @param {Event} event  The initial change event
   * @protected
   */
  async _onChangeInput(event) {

    // Saving a <prose-mirror> element
    if ( event.currentTarget.matches("prose-mirror") ) return this._onSubmit(event);

    // Ignore inputs inside an editor environment
    if ( event.currentTarget.closest(".editor") ) return;

    // Handle changes to specific input types
    const el = event.target;
    if ( (el.type === "color") && el.dataset.edit ) this._onChangeColorPicker(event);
    else if ( el.type === "range" ) this._onChangeRange(event);

    // Maybe submit the form
    if ( this.options.submitOnChange ) {
      return this._onSubmit(event);
    }
  }

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

  /**
   * Handle the change of a color picker input which enters it's chosen value into a related input field
   * @param {Event} event   The color picker change event
   * @protected
   */
  _onChangeColorPicker(event) {
    const input = event.target;
    input.form[input.dataset.edit].value = input.value;
  }

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

  /**
   * Handle changes to a range type input by propagating those changes to the sibling range-value element
   * @param {Event} event  The initial change event
   * @protected
   */
  _onChangeRange(event) {
    const field = event.target.parentElement.querySelector(".range-value");
    if ( field ) {
      if ( field.tagName === "INPUT" ) field.value = event.target.value;
      else field.innerHTML = event.target.value;
    }
  }

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

  /**
   * This method is called upon form submission after form data is validated
   * @param {Event} event       The initial triggering submission event
   * @param {object} formData   The object of validated form data with which to update the object
   * @returns {Promise}         A Promise which resolves once the update operation has completed
   * @abstract
   */
  async _updateObject(event, formData) {
    throw new Error("A subclass of the FormApplication must implement the _updateObject method.");
  }

  /* -------------------------------------------- */
  /*  TinyMCE Editor                              */
  /* -------------------------------------------- */

  /**
   * Activate a named TinyMCE text editor
   * @param {string} name             The named data field which the editor modifies.
   * @param {object} options          Editor initialization options passed to {@link TextEditor.create}.
   * @param {string} initialContent   Initial text content for the editor area.
   * @returns {Promise<TinyMCE.Editor|ProseMirror.EditorView>}
   */
  async activateEditor(name, options={}, initialContent="") {
    const editor = this.editors[name];
    if ( !editor ) throw new Error(`${name} is not a registered editor name!`);
    options = foundry.utils.mergeObject(editor.options, options);
    if ( !options.fitToSize ) options.height = options.target.offsetHeight;
    if ( editor.hasButton ) editor.button.style.display = "none";
    const instance = editor.instance = editor.mce = await TextEditor.create(options, initialContent || editor.initial);
    options.target.closest(".editor")?.classList.add(options.engine ?? "tinymce");
    editor.changed = false;
    editor.active = true;

    // Legacy behavior to support TinyMCE.
    // We could remove this in the future if we drop official support for TinyMCE.
    if ( options.engine !== "prosemirror" ) {
      instance.focus();
      instance.on("change", () => editor.changed = true);
    }
    return instance;
  }

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

  /**
   * Handle saving the content of a specific editor by name
   * @param {string} name                      The named editor to save
   * @param {object} [options]
   * @param {boolean} [options.remove]         Remove the editor after saving its content
   * @param {boolean} [options.preventRender]  Prevent normal re-rendering of the sheet after saving.
   * @returns {Promise<void>}
   */
  async saveEditor(name, {remove=true, preventRender}={}) {
    const editor = this.editors[name];
    if ( !editor || !editor.instance ) throw new Error(`${name} is not an active editor name!`);
    editor.active = false;
    const instance = editor.instance;
    await this._onSubmit(new Event("submit"), { preventRender });

    // Remove the editor
    if ( remove ) {
      instance.destroy();
      editor.instance = editor.mce = null;
      if ( editor.hasButton ) editor.button.style.display = "block";
      this.render();
    }
    editor.changed = false;
  }

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

  /**
   * Activate an editor instance present within the form
   * @param {HTMLElement} div  The element which contains the editor
   * @protected
   */
  _activateEditor(div) {

    // Get the editor content div
    const name = div.dataset.edit;
    const engine = div.dataset.engine || "tinymce";
    const collaborate = div.dataset.collaborate === "true";
    const button = div.previousElementSibling;
    const hasButton = button && button.classList.contains("editor-edit");
    const wrap = div.parentElement.parentElement;
    const wc = div.closest(".window-content");

    // Determine the preferred editor height
    const heights = [wrap.offsetHeight, wc ? wc.offsetHeight : null];
    if ( div.offsetHeight > 0 ) heights.push(div.offsetHeight);
    const height = Math.min(...heights.filter(h => Number.isFinite(h)));

    // Get initial content
    const options = {
      target: div,
      fieldName: name,
      save_onsavecallback: () => this.saveEditor(name),
      height, engine, collaborate
    };
    if ( engine === "prosemirror" ) options.plugins = this._configureProseMirrorPlugins(name, {remove: hasButton});

    // Define the editor configuration
    const initial = foundry.utils.getProperty(this.object, name);
    const editor = this.editors[name] = {
      options,
      target: name,
      button: button,
      hasButton: hasButton,
      mce: null,
      instance: null,
      active: !hasButton,
      changed: false,
      initial
    };

    // Activate the editor immediately, or upon button click
    const activate = () => {
      editor.initial = foundry.utils.getProperty(this.object, name);
      this.activateEditor(name, {}, editor.initial);
    };
    if ( hasButton ) button.onclick = activate;
    else activate();
  }

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

  /**
   * Configure ProseMirror plugins for this sheet.
   * @param {string} name                    The name of the editor.
   * @param {object} [options]               Additional options to configure the plugins.
   * @param {boolean} [options.remove=true]  Whether the editor should destroy itself on save.
   * @returns {object}
   * @protected
   */
  _configureProseMirrorPlugins(name, {remove=true}={}) {
    return {
      menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
        destroyOnSave: remove,
        onSave: () => this.saveEditor(name, {remove})
      }),
      keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema, {
        onSave: () => this.saveEditor(name, {remove})
      })
    };
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  async close(options={}) {
    const states = Application.RENDER_STATES;
    if ( !options.force && ![states.RENDERED, states.ERROR].includes(this._state) ) return;

    // Trigger saving of the form
    const submit = options.submit ?? this.options.submitOnClose;
    if ( submit ) await this.submit({preventClose: true, preventRender: true});

    // Close any open FilePicker instances
    for ( let fp of (this.#filepickers) ) fp.close();
    this.#filepickers.length = 0;
    for ( const fp of this.element[0].querySelectorAll("file-picker") ) fp.picker?.close();

    // Close any open MCE editors
    for ( let ed of Object.values(this.editors) ) {
      if ( ed.mce ) ed.mce.destroy();
    }
    this.editors = {};

    // Close the application itself
    return super.close(options);
  }

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

  /**
   * Submit the contents of a Form Application, processing its content as defined by the Application
   * @param {object} [options]            Options passed to the _onSubmit event handler
   * @returns {Promise<FormApplication>}  Return a self-reference for convenient method chaining
   */
  async submit(options={}) {
    if ( this._submitting ) return this;
    const submitEvent = new Event("submit");
    await this._onSubmit(submitEvent, options);
    return this;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  get filepickers() {
    foundry.utils.logCompatibilityWarning("FormApplication#filepickers is deprecated and replaced by the <file-picker>"
      + "HTML element", {since: 12, until: 14, once: true});
    return this.#filepickers;
  }

  #filepickers = [];

  /**
   * @deprecated since v12
   * @ignore
   */
  _activateFilePicker(event) {
    foundry.utils.logCompatibilityWarning("FormApplication#_activateFilePicker is deprecated without replacement",
      {since: 12, until: 14, once: true});
    event.preventDefault();
    const options = this._getFilePickerOptions(event);
    const fp = new FilePicker(options);
    this.#filepickers.push(fp);
    return fp.browse();
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  _getFilePickerOptions(event) {
    foundry.utils.logCompatibilityWarning("FormApplication#_getFilePickerOptions is deprecated without replacement",
      {since: 12, until: 14, once: true});
    const button = event.currentTarget;
    const target = button.dataset.target;
    const field = button.form[target] || null;
    return {
      field: field,
      type: button.dataset.type,
      current: field?.value ?? "",
      button: button,
      callback: this._onSelectFile.bind(this)
    };
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  _onSelectFile(selection, filePicker) {}
}


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

/**
 * @typedef {FormApplicationOptions} DocumentSheetOptions
 * @property {number} viewPermission                The default permissions required to view this Document sheet.
 * @property {HTMLSecretConfiguration[]} [secrets]  An array of {@link HTMLSecret} configuration objects.
 */

/**
 * Extend the FormApplication pattern to incorporate specific logic for viewing or editing Document instances.
 * See the FormApplication documentation for more complete description of this interface.
 *
 * @extends {FormApplication}
 * @abstract
 * @interface
 */
class DocumentSheet extends FormApplication {
  /**
   * @param {Document} object                    A Document instance which should be managed by this form.
   * @param {DocumentSheetOptions} [options={}]  Optional configuration parameters for how the form behaves.
   */
  constructor(object, options={}) {
    super(object, options);
    this._secrets = this._createSecretHandlers();
  }

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

  /**
   * The list of handlers for secret block functionality.
   * @type {HTMLSecret[]}
   * @protected
   */
  _secrets = [];

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

  /**
   * @override
   * @returns {DocumentSheetOptions}
   */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["sheet"],
      template: `templates/sheets/${this.name.toLowerCase()}.html`,
      viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED,
      sheetConfig: true,
      secrets: []
    });
  }

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

  /**
   * A semantic convenience reference to the Document instance which is the target object for this form.
   * @type {ClientDocument}
   */
  get document() {
    return this.object;
  }

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

  /** @inheritdoc */
  get id() {
    return `${this.constructor.name}-${this.document.uuid.replace(/\./g, "-")}`;
  }

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

  /** @inheritdoc */
  get isEditable() {
    let editable = this.options.editable && this.document.isOwner;
    if ( this.document.pack ) {
      const pack = game.packs.get(this.document.pack);
      if ( pack.locked ) editable = false;
    }
    return editable;
  }

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

  /** @inheritdoc */
  get title() {
    const reference = this.document.name ? `: ${this.document.name}` : "";
    return `${game.i18n.localize(this.document.constructor.metadata.label)}${reference}`;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  async close(options={}) {
    await super.close(options);
    delete this.object.apps?.[this.appId];
  }

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

  /** @inheritdoc */
  getData(options={}) {
    const data = this.document.toObject(false);
    const isEditable = this.isEditable;
    return {
      cssClass: isEditable ? "editable" : "locked",
      editable: isEditable,
      document: this.document,
      data: data,
      limited: this.document.limited,
      options: this.options,
      owner: this.document.isOwner,
      title: this.title
    };
  }

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

  /** @inheritdoc */
  _activateCoreListeners(html) {
    super._activateCoreListeners(html);
    if ( this.isEditable ) html.find("img[data-edit]").on("click", this._onEditImage.bind(this));
    if ( !this.document.isOwner ) return;
    this._secrets.forEach(secret => secret.bind(html[0]));
  }

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

  /** @inheritdoc */
  async activateEditor(name, options={}, initialContent="") {
    const editor = this.editors[name];
    options.document = this.document;
    if ( editor?.options.engine === "prosemirror" ) {
      options.plugins = foundry.utils.mergeObject({
        highlightDocumentMatches: ProseMirror.ProseMirrorHighlightMatchesPlugin.build(ProseMirror.defaultSchema)
      }, options.plugins);
    }
    return super.activateEditor(name, options, initialContent);
  }

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

  /** @inheritDoc */
  async _render(force, options={}) {

    // Verify user permission to view and edit
    if ( !this._canUserView(game.user) ) {
      if ( !force ) return;
      const err = game.i18n.format("SHEETS.DocumentSheetPrivate", {
        type: game.i18n.localize(this.object.constructor.metadata.label)
      });
      ui.notifications.warn(err);
      return;
    }
    options.editable = options.editable ?? this.object.isOwner;

    // Parent class rendering workflow
    await super._render(force, options);

    // Register the active Application with the referenced Documents
    this.object.apps[this.appId] = this;
  }

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

  /** @inheritDoc */
  async _renderOuter() {
    const html = await super._renderOuter();
    this._createDocumentIdLink(html);
    return html;
  }

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

  /**
   * Create an ID link button in the document sheet header which displays the document ID and copies to clipboard
   * @param {jQuery} html
   * @protected
   */
  _createDocumentIdLink(html) {
    if ( !(this.object instanceof foundry.abstract.Document) || !this.object.id ) return;
    const title = html.find(".window-title");
    const label = game.i18n.localize(this.object.constructor.metadata.label);
    const idLink = document.createElement("a");
    idLink.classList.add("document-id-link");
    idLink.ariaLabel = game.i18n.localize("SHEETS.CopyUuid");
    idLink.dataset.tooltip = `SHEETS.CopyUuid`;
    idLink.dataset.tooltipDirection = "UP";
    idLink.innerHTML = '<i class="fa-solid fa-passport"></i>';
    idLink.addEventListener("click", event => {
      event.preventDefault();
      game.clipboard.copyPlainText(this.object.uuid);
      ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "uuid", id: this.object.uuid}));
    });
    idLink.addEventListener("contextmenu", event => {
      event.preventDefault();
      game.clipboard.copyPlainText(this.object.id);
      ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", {label, type: "id", id: this.object.id}));
    });
    title.append(idLink);
  }

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

  /**
   * Test whether a certain User has permission to view this Document Sheet.
   * @param {User} user     The user requesting to render the sheet
   * @returns {boolean}     Does the User have permission to view this sheet?
   * @protected
   */
  _canUserView(user) {
    return this.object.testUserPermission(user, this.options.viewPermission);
  }

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

  /**
   * Create objects for managing the functionality of secret blocks within this Document's content.
   * @returns {HTMLSecret[]}
   * @protected
   */
  _createSecretHandlers() {
    if ( !this.document.isOwner || this.document.compendium?.locked ) return [];
    return this.options.secrets.map(config => {
      config.callbacks = {
        content: this._getSecretContent.bind(this),
        update: this._updateSecret.bind(this)
      };
      return new HTMLSecret(config);
    });
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _getHeaderButtons() {
    let buttons = super._getHeaderButtons();

    // Compendium Import
    if ( (this.document.constructor.name !== "Folder") && !this.document.isEmbedded &&
          this.document.compendium && this.document.constructor.canUserCreate(game.user) ) {
      buttons.unshift({
        label: "Import",
        class: "import",
        icon: "fas fa-download",
        onclick: async () => {
          await this.close();
          return this.document.collection.importFromCompendium(this.document.compendium, this.document.id);
        }
      });
    }

    // Sheet Configuration
    if ( this.options.sheetConfig && this.isEditable && (this.document.getFlag("core", "sheetLock") !== true) ) {
      buttons.unshift({
        label: "Sheet",
        class: "configure-sheet",
        icon: "fas fa-cog",
        onclick: ev => this._onConfigureSheet(ev)
      });
    }
    return buttons;
  }

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

  /**
   * Get the HTML content that a given secret block is embedded in.
   * @param {HTMLElement} secret  The secret block.
   * @returns {string}
   * @protected
   */
  _getSecretContent(secret) {
    const edit = secret.closest("[data-edit]")?.dataset.edit;
    if ( edit ) return foundry.utils.getProperty(this.document, edit);
  }

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

  /**
   * Update the HTML content that a given secret block is embedded in.
   * @param {HTMLElement} secret         The secret block.
   * @param {string} content             The new content.
   * @returns {Promise<ClientDocument>}  The updated Document.
   * @protected
   */
  _updateSecret(secret, content) {
    const edit = secret.closest("[data-edit]")?.dataset.edit;
    if ( edit ) return this.document.update({[edit]: content});
  }

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

  /**
   * Handle requests to configure the default sheet used by this Document
   * @param event
   * @private
   */
  _onConfigureSheet(event) {
    event.preventDefault();
    new DocumentSheetConfig(this.document, {
      top: this.position.top + 40,
      left: this.position.left + ((this.position.width - DocumentSheet.defaultOptions.width) / 2)
    }).render(true);
  }

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

  /**
   * Handle changing a Document's image.
   * @param {MouseEvent} event  The click event.
   * @returns {Promise}
   * @protected
   */
  _onEditImage(event) {
    const attr = event.currentTarget.dataset.edit;
    const current = foundry.utils.getProperty(this.object, attr);
    const { img } = this.document.constructor.getDefaultArtwork?.(this.document.toObject()) ?? {};
    const fp = new FilePicker({
      current,
      type: "image",
      redirectToRoot: img ? [img] : [],
      callback: path => {
        event.currentTarget.src = path;
        if ( this.options.submitOnChange ) return this._onSubmit(event);
      },
      top: this.position.top + 40,
      left: this.position.left + 10
    });
    return fp.browse();
  }

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

  /** @inheritdoc */
  async _updateObject(event, formData) {
    if ( !this.object.id ) return;
    return this.object.update(formData);
  }
}

/**
 * A helper class which assists with localization and string translation
 * @param {string} serverLanguage       The default language configuration setting for the server
 */
class Localization {
  constructor(serverLanguage) {

    // Obtain the default language from application settings
    const [defaultLanguage, defaultModule] = (serverLanguage || "en.core").split(".");

    /**
     * The target language for localization
     * @type {string}
     */
    this.lang = defaultLanguage;

    /**
     * The package authorized to provide default language configurations
     * @type {string}
     */
    this.defaultModule = defaultModule;

    /**
     * The translation dictionary for the target language
     * @type {Object}
     */
    this.translations = {};

    /**
     * Fallback translations if the target keys are not found
     * @type {Object}
     */
    this._fallback = {};
  }

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

  /**
   * Cached store of Intl.ListFormat instances.
   * @type {Record<string, Intl.ListFormat>}
   */
  #formatters = {};

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

  /**
   * Initialize the Localization module
   * Discover available language translations and apply the current language setting
   * @returns {Promise<void>}      A Promise which resolves once languages are initialized
   */
  async initialize() {
    const clientLanguage = await game.settings.get("core", "language") || this.lang;

    // Discover which modules available to the client
    this._discoverSupportedLanguages();

    // Activate the configured language
    if ( clientLanguage !== this.lang ) this.defaultModule = "core";
    await this.setLanguage(clientLanguage || this.lang);

    // Define type labels
    if ( game.system ) {
      for ( let [documentName, types] of Object.entries(game.documentTypes) ) {
        const config = CONFIG[documentName];
        config.typeLabels = config.typeLabels || {};
        for ( const t of types ) {
          if ( config.typeLabels[t] ) continue;
          const key = t === CONST.BASE_DOCUMENT_TYPE ? "TYPES.Base" :`TYPES.${documentName}.${t}`;
          config.typeLabels[t] = key;

          /** @deprecated since v11 */
          const legacyKey = `${documentName.toUpperCase()}.Type${t.titleCase()}`;
          if ( !this.has(key) && this.has(legacyKey) ) {
            foundry.utils.logCompatibilityWarning(
              `You are using the '${legacyKey}' localization key which has been deprecated. `
              + `Please define a '${key}' key instead.`,
              {since: 11, until: 13}
            );
            config.typeLabels[t] = legacyKey;
          }
        }
      }
    }

    // Pre-localize data models
    Localization.#localizeDataModels();
    Hooks.callAll("i18nInit");
  }

  /* -------------------------------------------- */
  /*  Data Model Localization                     */
  /* -------------------------------------------- */

  /**
   * Perform one-time localization of the fields in a DataModel schema, translating their label and hint properties.
   * @param {typeof DataModel} model          The DataModel class to localize
   * @param {object} options                  Options which configure how localization is performed
   * @param {string[]} [options.prefixes]       An array of localization key prefixes to use. If not specified, prefixes
   *                                            are learned from the DataModel.LOCALIZATION_PREFIXES static property.
   * @param {string} [options.prefixPath]       A localization path prefix used to prefix all field names within this
   *                                            model. This is generally not required.
   *
   * @example
   * JavaScript class definition and localization call.
   * ```js
   * class MyDataModel extends foundry.abstract.DataModel {
   *   static defineSchema() {
   *     return {
   *       foo: new foundry.data.fields.StringField(),
   *       bar: new foundry.data.fields.NumberField()
   *     };
   *   }
   *   static LOCALIZATION_PREFIXES = ["MYMODULE.MYDATAMODEL"];
   * }
   *
   * Hooks.on("i18nInit", () => {
   *   Localization.localizeDataModel(MyDataModel);
   * });
   * ```
   *
   * JSON localization file
   * ```json
   * {
   *   "MYMODULE": {
   *     "MYDATAMODEL": {
   *       "FIELDS" : {
   *         "foo": {
   *           "label": "Foo",
   *           "hint": "Instructions for foo"
   *         },
   *         "bar": {
   *           "label": "Bar",
   *           "hint": "Instructions for bar"
   *         }
   *       }
   *     }
   *   }
   * }
   * ```
   */
  static localizeDataModel(model, {prefixes, prefixPath}={}) {
    prefixes ||= model.LOCALIZATION_PREFIXES;
    Localization.#localizeSchema(model.schema, prefixes, {prefixPath});
  }

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

  /**
   * Perform one-time localization of data model definitions which localizes their label and hint properties.
   */
  static #localizeDataModels() {
    for ( const document of Object.values(foundry.documents) ) {
      const cls = document.implementation;
      Localization.localizeDataModel(cls);
      for ( const model of Object.values(CONFIG[cls.documentName].dataModels ?? {}) ) {
        Localization.localizeDataModel(model, {prefixPath: "system."});
      }
    }
  }

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

  /**
   * Localize the "label" and "hint" properties for all fields in a data schema.
   * @param {SchemaField} schema
   * @param {string[]} prefixes
   * @param {object} [options]
   * @param {string} [options.prefixPath]
   */
  static #localizeSchema(schema, prefixes=[], {prefixPath=""}={}) {
    const getRules = prefixes => {
      const rules = {};
      for ( const prefix of prefixes ) {
        if ( game.i18n.lang !== "en" ) {
          const fallback = foundry.utils.getProperty(game.i18n._fallback, `${prefix}.FIELDS`);
          Object.assign(rules, fallback);
        }
        Object.assign(rules, foundry.utils.getProperty(game.i18n.translations, `${prefix}.FIELDS`));
      }
      return rules;
    };
    const rules = getRules(prefixes);

    // Apply localization to fields of the model
    schema.apply(function() {

      // Inner models may have prefixes which take precedence
      if ( this instanceof foundry.data.fields.EmbeddedDataField ) {
        if ( this.model.LOCALIZATION_PREFIXES.length ) {
          foundry.utils.setProperty(rules, this.fieldPath, getRules(this.model.LOCALIZATION_PREFIXES));
        }
      }

      // Localize model fields
      let k = this.fieldPath;
      if ( prefixPath ) k = k.replace(prefixPath, "");
      const field = foundry.utils.getProperty(rules, k);
      if ( field?.label ) this.label = game.i18n.localize(field.label);
      if ( field?.hint ) this.hint = game.i18n.localize(field.hint);
    });
  }

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

  /**
   * Set a language as the active translation source for the session
   * @param {string} lang       A language string in CONFIG.supportedLanguages
   * @returns {Promise<void>}   A Promise which resolves once the translations for the requested language are ready
   */
  async setLanguage(lang) {
    if ( !Object.keys(CONFIG.supportedLanguages).includes(lang) ) {
      console.error(`Cannot set language ${lang}, as it is not in the supported set. Falling back to English`);
      lang = "en";
    }
    this.lang = lang;
    document.documentElement.setAttribute("lang", this.lang);

    // Load translations and English fallback strings
    this.translations = await this._getTranslations(lang);
    if ( lang !== "en" ) this._fallback = await this._getTranslations("en");
  }

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

  /**
   * Discover the available supported languages from the set of packages which are provided
   * @returns {object}         The resulting configuration of supported languages
   * @private
   */
  _discoverSupportedLanguages() {
    const sl = CONFIG.supportedLanguages;

    // Define packages
    const packages = Array.from(game.modules.values());
    if ( game.world ) packages.push(game.world);
    if ( game.system ) packages.push(game.system);
    if ( game.worlds ) packages.push(...game.worlds.values());
    if ( game.systems ) packages.push(...game.systems.values());

    // Registration function
    const register = pkg => {
      if ( !pkg.languages.size ) return;
      for ( let l of pkg.languages ) {
        if ( !sl.hasOwnProperty(l.lang) ) sl[l.lang] = l.name;
      }
    };

    // Register core translation languages first
    for ( let m of game.modules ) {
      if ( m.coreTranslation ) register(m);
    }

    // Discover and register languages
    for ( let p of packages ) {
      if ( p.coreTranslation || ((p.type === "module") && !p.active) ) continue;
      register(p);
    }
    return sl;
  }

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

  /**
   * Prepare the dictionary of translation strings for the requested language
   * @param {string} lang         The language for which to load translations
   * @returns {Promise<object>}   The retrieved translations object
   * @private
   */
  async _getTranslations(lang) {
    const translations = {};
    const promises = [];

    // Include core supported translations
    if ( CONST.CORE_SUPPORTED_LANGUAGES.includes(lang) ) {
      promises.push(this._loadTranslationFile(`lang/${lang}.json`));
    }

    // Game system translations
    if ( game.system ) {
      this._filterLanguagePaths(game.system, lang).forEach(path => {
        promises.push(this._loadTranslationFile(path));
      });
    }

    // Module translations
    for ( let module of game.modules.values() ) {
      if ( !module.active && (module.id !== this.defaultModule) ) continue;
      this._filterLanguagePaths(module, lang).forEach(path => {
        promises.push(this._loadTranslationFile(path));
      });
    }

    // Game world translations
    if ( game.world ) {
      this._filterLanguagePaths(game.world, lang).forEach(path => {
        promises.push(this._loadTranslationFile(path));
      });
    }

    // Merge translations in load order and return the prepared dictionary
    await Promise.all(promises);
    for ( let p of promises ) {
      let json = await p;
      foundry.utils.mergeObject(translations, json, {inplace: true});
    }
    return translations;
  }

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

  /**
   * Reduce the languages array provided by a package to an array of file paths of translations to load
   * @param {object} pkg          The package data
   * @param {string} lang         The target language to filter on
   * @returns {string[]}           An array of translation file paths
   * @private
   */
  _filterLanguagePaths(pkg, lang) {
    return pkg.languages.reduce((arr, l) => {
      if ( l.lang !== lang ) return arr;
      let checkSystem = !l.system || (game.system && (l.system === game.system.id));
      let checkModule = !l.module || game.modules.get(l.module)?.active;
      if (checkSystem && checkModule) arr.push(l.path);
      return arr;
    }, []);
  }

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

  /**
   * Load a single translation file and return its contents as processed JSON
   * @param {string} src        The translation file path to load
   * @returns {Promise<object>} The loaded translation dictionary
   * @private
   */
  async _loadTranslationFile(src) {

    // Load the referenced translation file
    let err;
    const resp = await fetch(src).catch(e => {
      err = e;
      return {};
    });
    if ( resp.status !== 200 ) {
      const msg = `Unable to load requested localization file ${src}`;
      console.error(`${vtt} | ${msg}`);
      if ( err ) Hooks.onError("Localization#_loadTranslationFile", err, {msg, src});
      return {};
    }

    // Parse and expand the provided translation object
    let json;
    try {
      json = await resp.json();
      console.log(`${vtt} | Loaded localization file ${src}`);
      json = foundry.utils.expandObject(json);
    } catch(err) {
      Hooks.onError("Localization#_loadTranslationFile", err, {
        msg: `Unable to parse localization file ${src}`,
        log: "error",
        src
      });
      json = {};
    }
    return json;
  }

  /* -------------------------------------------- */
  /*  Localization API                            */
  /* -------------------------------------------- */

  /**
   * Return whether a certain string has a known translation defined.
   * @param {string} stringId     The string key being translated
   * @param {boolean} [fallback]  Allow fallback translations to count?
   * @returns {boolean}
   */
  has(stringId, fallback=true) {
    let v = foundry.utils.getProperty(this.translations, stringId);
    if ( typeof v === "string" ) return true;
    if ( !fallback ) return false;
    v = foundry.utils.getProperty(this._fallback, stringId);
    return typeof v === "string";
  }

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

  /**
   * Localize a string by drawing a translation from the available translations dictionary, if available
   * If a translation is not available, the original string is returned
   * @param {string} stringId     The string ID to translate
   * @returns {string}             The translated string
   *
   * @example Localizing a simple string in JavaScript
   * ```js
   * {
   *   "MYMODULE.MYSTRING": "Hello, this is my module!"
   * }
   * game.i18n.localize("MYMODULE.MYSTRING"); // Hello, this is my module!
   * ```
   *
   * @example Localizing a simple string in Handlebars
   * ```hbs
   * {{localize "MYMODULE.MYSTRING"}} <!-- Hello, this is my module! -->
   * ```
   */
  localize(stringId) {
    let v = foundry.utils.getProperty(this.translations, stringId);
    if ( typeof v === "string" ) return v;
    v = foundry.utils.getProperty(this._fallback, stringId);
    return typeof v === "string" ? v : stringId;
  }

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

  /**
   * Localize a string including variable formatting for input arguments.
   * Provide a string ID which defines the localized template.
   * Variables can be included in the template enclosed in braces and will be substituted using those named keys.
   *
   * @param {string} stringId     The string ID to translate
   * @param {object} data         Provided input data
   * @returns {string}             The translated and formatted string
   *
   * @example Localizing a formatted string in JavaScript
   * ```js
   * {
   *   "MYMODULE.GREETING": "Hello {name}, this is my module!"
   * }
   * game.i18n.format("MYMODULE.GREETING" {name: "Andrew"}); // Hello Andrew, this is my module!
   * ```
   *
   * @example Localizing a formatted string in Handlebars
   * ```hbs
   * {{localize "MYMODULE.GREETING" name="Andrew"}} <!-- Hello, this is my module! -->
   * ```
   */
  format(stringId, data={}) {
    let str = this.localize(stringId);
    const fmt = /{[^}]+}/g;
    str = str.replace(fmt, k => {
      return data[k.slice(1, -1)];
    });
    return str;
  }

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

  /**
   * Retrieve list formatter configured to the world's language setting.
   * @see [Intl.ListFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat/ListFormat)
   * @param {object} [options]
   * @param {ListFormatStyle} [options.style=long]       The list formatter style, either "long", "short", or "narrow".
   * @param {ListFormatType} [options.type=conjunction]  The list formatter type, either "conjunction", "disjunction",
   *                                                     or "unit".
   * @returns {Intl.ListFormat}
   */
  getListFormatter({style="long", type="conjunction"}={}) {
    const key = `${style}${type}`;
    this.#formatters[key] ??= new Intl.ListFormat(this.lang, {style, type});
    return this.#formatters[key];
  }

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

  /**
   * Sort an array of objects by a given key in a localization-aware manner.
   * @param {object[]} objects  The objects to sort, this array will be mutated.
   * @param {string} key        The key to sort the objects by. This can be provided in dot-notation.
   * @returns {object[]}
   */
  sortObjects(objects, key) {
    const collator = new Intl.Collator(this.lang);
    objects.sort((a, b) => {
      return collator.compare(foundry.utils.getProperty(a, key), foundry.utils.getProperty(b, key));
    });
    return objects;
  }
}


/* -------------------------------------------- */
/*  HTML Template Loading                       */
/* -------------------------------------------- */

/**
 * Get a template from the server by fetch request and caching the retrieved result
 * @param {string} path           The web-accessible HTML template URL
 * @param {string} [id]           An ID to register the partial with.
 * @returns {Promise<Function>}   A Promise which resolves to the compiled Handlebars template
 */
async function getTemplate(path, id) {
  if ( path in Handlebars.partials ) return Handlebars.partials[path];
  const htmlString = await new Promise((resolve, reject) => {
    game.socket.emit("template", path, resp => {
      if ( resp.error ) return reject(new Error(resp.error));
      return resolve(resp.html);
    });
  });
  const compiled = Handlebars.compile(htmlString);
  Handlebars.registerPartial(id ?? path, compiled);
  console.log(`Foundry VTT | Retrieved and compiled template ${path}`);
  return compiled;
}

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

/**
 * Load and cache a set of templates by providing an Array of paths
 * @param {string[]|Record<string, string>} paths  An array of template file paths to load, or an object of Handlebars partial
 *                                         IDs to paths.
 * @returns {Promise<Function[]>}
 *
 * @example Loading a list of templates.
 * ```js
 * await loadTemplates(["templates/apps/foo.html", "templates/apps/bar.html"]);
 * ```
 * ```hbs
 * <!-- Include a pre-loaded template as a partial -->
 * {{> "templates/apps/foo.html" }}
 * ```
 *
 * @example Loading an object of templates.
 * ```js
 * await loadTemplates({
 *   foo: "templates/apps/foo.html",
 *   bar: "templates/apps/bar.html"
 * });
 * ```
 * ```hbs
 * <!-- Include a pre-loaded template as a partial -->
 * {{> foo }}
 * ```
 */
async function loadTemplates(paths) {
  let promises;
  if ( foundry.utils.getType(paths) === "Object" ) promises = Object.entries(paths).map(([k, p]) => getTemplate(p, k));
  else promises = paths.map(p => getTemplate(p));
  return Promise.all(promises);
}

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


/**
 * Get and render a template using provided data and handle the returned HTML
 * Support asynchronous file template file loading with a client-side caching layer
 *
 * Allow resolution of prototype methods and properties since this all occurs within the safety of the client.
 * @see {@link https://handlebarsjs.com/api-reference/runtime-options.html#options-to-control-prototype-access}
 *
 * @param {string} path             The file path to the target HTML template
 * @param {Object} data             A data object against which to compile the template
 *
 * @returns {Promise<string>}        Returns the compiled and rendered template as a string
 */
async function renderTemplate(path, data) {
  const template = await getTemplate(path);
  return template(data || {}, {
    allowProtoMethodsByDefault: true,
    allowProtoPropertiesByDefault: true
  });
}


/* -------------------------------------------- */
/*  Handlebars Template Helpers                 */
/* -------------------------------------------- */

// Register Handlebars Extensions
HandlebarsIntl.registerWith(Handlebars);

/**
 * A collection of Handlebars template helpers which can be used within HTML templates.
 */
class HandlebarsHelpers {

  /**
   * For checkboxes, if the value of the checkbox is true, add the "checked" property, otherwise add nothing.
   * @returns {string}
   *
   * @example
   * ```hbs
   * <label>My Checkbox</label>
   * <input type="checkbox" name="myCheckbox" {{checked myCheckbox}}>
   * ```
   */
  static checked(value) {
    return Boolean(value) ? "checked" : "";
  }

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

  /**
   * For use in form inputs. If the supplied value is truthy, add the "disabled" property, otherwise add nothing.
   * @returns {string}
   *
   * @example
   * ```hbs
   * <button type="submit" {{disabled myValue}}>Submit</button>
   * ```
   */
  static disabled(value) {
    return value ? "disabled" : "";
  }

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

  /**
   * Concatenate a number of string terms into a single string.
   * This is useful for passing arguments with variable names.
   * @param {string[]} values             The values to concatenate
   * @returns {Handlebars.SafeString}
   *
   * @example Concatenate several string parts to create a dynamic variable
   * ```hbs
   * {{filePicker target=(concat "faces." i ".img") type="image"}}
   * ```
   */
  static concat(...values) {
    const options = values.pop();
    const join = options.hash?.join || "";
    return new Handlebars.SafeString(values.join(join));
  }

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

  /**
   * Construct an editor element for rich text editing with TinyMCE or ProseMirror.
   * @param {string} content                       The content to display and edit.
   * @param {object} [options]
   * @param {string} [options.target]              The named target data element
   * @param {boolean} [options.button]             Include a button used to activate the editor later?
   * @param {string} [options.class]               A specific CSS class to add to the editor container
   * @param {boolean} [options.editable=true]      Is the text editor area currently editable?
   * @param {string} [options.engine=tinymce]      The editor engine to use, see {@link TextEditor.create}.
   * @param {boolean} [options.collaborate=false]  Whether to turn on collaborative editing features for ProseMirror.
   * @returns {Handlebars.SafeString}
   *
   * @example
   * ```hbs
   * {{editor world.description target="description" button=false engine="prosemirror" collaborate=false}}
   * ```
   */
  static editor(content, options) {
    const { target, editable=true, button, engine="tinymce", collaborate=false, class: cssClass } = options.hash;
    const config = {name: target, value: content, button, collaborate, editable, engine};
    const element = foundry.applications.fields.createEditorInput(config);
    if ( cssClass ) element.querySelector(".editor-content").classList.add(cssClass);
    return new Handlebars.SafeString(element.outerHTML);
  }

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

  /**
   * A ternary expression that allows inserting A or B depending on the value of C.
   * @param {boolean} criteria    The test criteria
   * @param {string} ifTrue       The string to output if true
   * @param {string} ifFalse      The string to output if false
   * @returns {string}            The ternary result
   *
   * @example Ternary if-then template usage
   * ```hbs
   * {{ifThen true "It is true" "It is false"}}
   * ```
   */
  static ifThen(criteria, ifTrue, ifFalse) {
    return criteria ? ifTrue : ifFalse;
  }

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

  /**
   * Translate a provided string key by using the loaded dictionary of localization strings.
   * @returns {string}
   *
   * @example Translate a provided localization string, optionally including formatting parameters
   * ```hbs
   * <label>{{localize "ACTOR.Create"}}</label> <!-- "Create Actor" -->
   * <label>{{localize "CHAT.InvalidCommand" command=foo}}</label> <!-- "foo is not a valid chat message command." -->
   * ```
   */
  static localize(value, options) {
    if ( value instanceof Handlebars.SafeString ) value = value.toString();
    const data = options.hash;
    return foundry.utils.isEmpty(data) ? game.i18n.localize(value) : game.i18n.format(value, data);
  }

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

  /**
   * A string formatting helper to display a number with a certain fixed number of decimals and an explicit sign.
   * @param {number|string} value       A numeric value to format
   * @param {object} options            Additional options which customize the resulting format
   * @param {number} [options.decimals=0]   The number of decimal places to include in the resulting string
   * @param {boolean} [options.sign=false]  Whether to include an explicit "+" sign for positive numbers   *
   * @returns {Handlebars.SafeString}   The formatted string to be included in a template
   *
   * @example
   * ```hbs
   * {{formatNumber 5.5}} <!-- 5.5 -->
   * {{formatNumber 5.5 decimals=2}} <!-- 5.50 -->
   * {{formatNumber 5.5 decimals=2 sign=true}} <!-- +5.50 -->
   * {{formatNumber null decimals=2 sign=false}} <!-- NaN -->
   * {{formatNumber undefined decimals=0 sign=true}} <!-- NaN -->
   *  ```
   */
  static numberFormat(value, options) {
    const originalValue = value;
    const dec = options.hash.decimals ?? 0;
    const sign = options.hash.sign || false;
    if ( (typeof value === "string") || (value == null) ) value = parseFloat(value);
    if ( Number.isNaN(value) ) {
      console.warn("An invalid value was passed to numberFormat:", {
        originalValue,
        valueType: typeof originalValue,
        options
      });
    }
    let strVal = sign && (value >= 0) ? `+${value.toFixed(dec)}` : value.toFixed(dec);
    return new Handlebars.SafeString(strVal);
  }

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

  /**
   * Render a form input field of type number with value appropriately rounded to step size.
   * @param {number} value
   * @param {FormInputConfig<number> & NumberInputConfig} options
   * @returns {Handlebars.SafeString}
   *
   * @example
   * ```hbs
   * {{numberInput value name="numberField" step=1 min=0 max=10}}
   * ```
   */
  static numberInput(value, options) {
    const {class: cssClass, ...config} = options.hash;
    config.value = value;
    const element = foundry.applications.fields.createNumberInput(config);
    if ( cssClass ) element.className = cssClass;
    return new Handlebars.SafeString(element.outerHTML);
  }

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

  /**
   * A helper to create a set of radio checkbox input elements in a named set.
   * The provided keys are the possible radio values while the provided values are human readable labels.
   *
   * @param {string} name         The radio checkbox field name
   * @param {object} choices      A mapping of radio checkbox values to human readable labels
   * @param {object} options      Options which customize the radio boxes creation
   * @param {string} options.checked    Which key is currently checked?
   * @param {boolean} options.localize  Pass each label through string localization?
   * @returns {Handlebars.SafeString}
   *
   * @example The provided input data
   * ```js
   * let groupName = "importantChoice";
   * let choices = {a: "Choice A", b: "Choice B"};
   * let chosen = "a";
   * ```
   *
   * @example The template HTML structure
   * ```hbs
   * <div class="form-group">
   *   <label>Radio Group Label</label>
   *   <div class="form-fields">
   *     {{radioBoxes groupName choices checked=chosen localize=true}}
   *   </div>
   * </div>
   * ```
   */
  static radioBoxes(name, choices, options) {
    const checked = options.hash['checked'] || null;
    const localize = options.hash['localize'] || false;
    let html = "";
    for ( let [key, label] of Object.entries(choices) ) {
      if ( localize ) label = game.i18n.localize(label);
      const isChecked = checked === key;
      html += `<label class="checkbox"><input type="radio" name="${name}" value="${key}" ${isChecked ? "checked" : ""}> ${label}</label>`;
    }
    return new Handlebars.SafeString(html);
  }

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

  /**
   * Render a pair of inputs for selecting a value in a range.
   * @param {object} options            Helper options
   * @param {string} [options.name]     The name of the field to create
   * @param {number} [options.value]    The current range value
   * @param {number} [options.min]      The minimum allowed value
   * @param {number} [options.max]      The maximum allowed value
   * @param {number} [options.step]     The allowed step size
   * @returns {Handlebars.SafeString}
   *
   * @example
   * ```hbs
   * {{rangePicker name="foo" value=bar min=0 max=10 step=1}}
   * ```
   */
  static rangePicker(options) {
    let {name, value, min, max, step} = options.hash;
    name = name || "range";
    value = value ?? "";
    if ( Number.isNaN(value) ) value = "";
    const html =
    `<input type="range" name="${name}" value="${value}" min="${min}" max="${max}" step="${step}"/>
     <span class="range-value">${value}</span>`;
    return new Handlebars.SafeString(html);
  }

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

  /**
   * @typedef {Object} SelectOptionsHelperOptions
   * @property {boolean} invert     Invert the key/value order of a provided choices object
   * @property {string|string[]|Set<string>} selected  The currently selected value or values
   */

  /**
   * A helper to create a set of &lt;option> elements in a &lt;select> block based on a provided dictionary.
   * The provided keys are the option values while the provided values are human-readable labels.
   * This helper supports both single-select and multi-select input fields.
   *
   * @param {object|Array<object>} choices       A mapping of radio checkbox values to human-readable labels
   * @param {SelectInputConfig & SelectOptionsHelperOptions} options  Options which configure how select options are
   *                                            generated by the helper
   * @returns {Handlebars.SafeString}           Generated HTML safe for rendering into a Handlebars template
   *
   * @example The provided input data
   * ```js
   * let choices = {a: "Choice A", b: "Choice B"};
   * let value = "a";
   * ```
   * The template HTML structure
   * ```hbs
   * <select name="importantChoice">
   *   {{selectOptions choices selected=value localize=true}}
   * </select>
   * ```
   * The resulting HTML
   * ```html
   * <select name="importantChoice">
   *   <option value="a" selected>Choice A</option>
   *   <option value="b">Choice B</option>
   * </select>
   * ```
   *
   * @example Using inverted choices
   * ```js
   * let choices = {"Choice A": "a", "Choice B": "b"};
   * let value = "a";
   * ```
   *  The template HTML structure
   *  ```hbs
   * <select name="importantChoice">
   *   {{selectOptions choices selected=value inverted=true}}
   * </select>
   * ```
   *
   * @example Using nameAttr and labelAttr with objects
   * ```js
   * let choices = {foo: {key: "a", label: "Choice A"}, bar: {key: "b", label: "Choice B"}};
   * let value = "b";
   * ```
   * The template HTML structure
   * ```hbs
   * <select name="importantChoice">
   *   {{selectOptions choices selected=value nameAttr="key" labelAttr="label"}}
   * </select>
   * ```
   *
   * @example Using nameAttr and labelAttr with arrays
   * ```js
   * let choices = [{key: "a", label: "Choice A"}, {key: "b", label: "Choice B"}];
   * let value = "b";
   * ```
   * The template HTML structure
   * ```hbs
   * <select name="importantChoice">
   *   {{selectOptions choices selected=value nameAttr="key" labelAttr="label"}}
   * </select>
   * ```
   */
  static selectOptions(choices, options) {
    let {localize=false, selected, blank, sort, nameAttr, valueAttr, labelAttr, inverted, groups} = options.hash;
    if ( (selected === undefined) || (selected === null) ) selected = [];
    else if ( !(selected instanceof Array) ) selected = [selected];

    if ( nameAttr && !valueAttr ) {
      foundry.utils.logCompatibilityWarning(`The "nameAttr" property of the {{selectOptions}} handlebars helper is 
        renamed to "valueAttr" for consistency with other methods.`, {since: 12, until: 14});
      valueAttr = nameAttr;
    }

    // Prepare the choices as an array of objects
    const selectOptions = [];
    if ( choices instanceof Array ) {
      for ( const [i, choice] of choices.entries() ) {
        if ( typeof choice === "object" ) selectOptions.push(choice);
        else selectOptions.push({value: i, label: choice});
      }
    }

    // Object of keys and values
    else {
      for ( const choice of Object.entries(choices) ) {
        const [k, v] = inverted ? choice.reverse() : choice;
        const value = valueAttr ? v[valueAttr] : k;
        if ( typeof v === "object" ) selectOptions.push({value, ...v});
        else selectOptions.push({value, label: v});
      }
    }

    // Delegate to new fields helper
    const select = foundry.applications.fields.createSelectInput({
      options: selectOptions,
      value: selected,
      blank,
      groups,
      labelAttr,
      localize,
      sort,
      valueAttr
    });
    return new Handlebars.SafeString(select.innerHTML);
  }

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

  /**
   * Convert a DataField instance into an HTML input fragment.
   * @param {DataField} field             The DataField instance to convert to an input
   * @param {object} options              Helper options
   * @returns {Handlebars.SafeString}
   */
  static formInput(field, options) {
    const input = field.toInput(options.hash);
    return new Handlebars.SafeString(input.outerHTML);
  }

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

  /**
   * Convert a DataField instance into an HTML input fragment.
   * @param {DataField} field             The DataField instance to convert to an input
   * @param {object} options              Helper options
   * @returns {Handlebars.SafeString}
   */
  static formGroup(field, options) {
    const {classes, label, hint, rootId, stacked, units, widget, ...inputConfig} = options.hash;
    const groupConfig = {label, hint, rootId, stacked, widget, localize: inputConfig.localize, units,
      classes: typeof classes === "string" ? classes.split(" ") : []};
    const group = field.toFormGroup(groupConfig, inputConfig);
    return new Handlebars.SafeString(group.outerHTML);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static filePicker(options) {
    foundry.utils.logCompatibilityWarning("The {{filePicker}} Handlebars helper is deprecated and replaced by"
      + " use of the <file-picker> custom HTML element", {since: 12, until: 14, once: true});
    const type = options.hash.type;
    const target = options.hash.target;
    if ( !target ) throw new Error("You must define the name of the target field.");
    if ( game.world && !game.user.can("FILES_BROWSE" ) ) return "";
    const tooltip = game.i18n.localize("FILES.BrowseTooltip");
    return new Handlebars.SafeString(`
    <button type="button" class="file-picker" data-type="${type}" data-target="${target}" title="${tooltip}" tabindex="-1">
        <i class="fas fa-file-import fa-fw"></i>
    </button>`);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static colorPicker(options) {
    foundry.utils.logCompatibilityWarning("The {{colorPicker}} Handlebars helper is deprecated and replaced by"
      + " use of the <color-picker> custom HTML element", {since: 12, until: 14, once: true});
    let {name, default: defaultColor, value} = options.hash;
    name = name || "color";
    value = value || defaultColor || "";
    const htmlString = `<color-picker name="${name}" value="${value}"></color-picker>`;
    return new Handlebars.SafeString(htmlString);
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  static select(selected, options) {
    foundry.utils.logCompatibilityWarning("The {{select}} handlebars helper is deprecated in favor of using the "
      + "{{selectOptions}} helper or the foundry.applications.fields.createSelectInput, "
      + "foundry.applications.fields.createMultiSelectElement, or "
      + "foundry.applications.fields.prepareSelectOptionGroups methods.", {since: 12, until: 14});
    const escapedValue = RegExp.escape(Handlebars.escapeExpression(selected));
    const rgx = new RegExp(` value=[\"']${escapedValue}[\"\']`);
    const html = options.fn(this);
    return html.replace(rgx, "$& selected");
  }
}

// Register all handlebars helpers
Handlebars.registerHelper({
  checked: HandlebarsHelpers.checked,
  disabled: HandlebarsHelpers.disabled,
  colorPicker: HandlebarsHelpers.colorPicker,
  concat: HandlebarsHelpers.concat,
  editor: HandlebarsHelpers.editor,
  formInput: HandlebarsHelpers.formInput,
  formGroup: HandlebarsHelpers.formGroup,
  formField: HandlebarsHelpers.formGroup, // Alias
  filePicker: HandlebarsHelpers.filePicker,
  ifThen: HandlebarsHelpers.ifThen,
  numberFormat: HandlebarsHelpers.numberFormat,
  numberInput: HandlebarsHelpers.numberInput,
  localize: HandlebarsHelpers.localize,
  radioBoxes: HandlebarsHelpers.radioBoxes,
  rangePicker: HandlebarsHelpers.rangePicker,
  select: HandlebarsHelpers.select,
  selectOptions: HandlebarsHelpers.selectOptions,
  timeSince: foundry.utils.timeSince,
  eq: (v1, v2) => v1 === v2,
  ne: (v1, v2) => v1 !== v2,
  lt: (v1, v2) => v1 < v2,
  gt: (v1, v2) => v1 > v2,
  lte: (v1, v2) => v1 <= v2,
  gte: (v1, v2) => v1 >= v2,
  not: pred => !pred,
  and() {return Array.prototype.every.call(arguments, Boolean);},
  or() {return Array.prototype.slice.call(arguments, 0, -1).some(Boolean);}
});

/**
 * The core Game instance which encapsulates the data, settings, and states relevant for managing the game experience.
 * The singleton instance of the Game class is available as the global variable game.
 */
class Game {
  /**
   * Initialize a singleton Game instance for a specific view using socket data retrieved from the server.
   * @param {string} view         The named view which is active for this game instance.
   * @param {object} data         An object of all the World data vended by the server when the client first connects
   * @param {string} sessionId    The ID of the currently active client session retrieved from the browser cookie
   * @param {Socket} socket       The open web-socket which should be used to transact game-state data
   */
  constructor(view, data, sessionId, socket) {

    // Session Properties
    Object.defineProperties(this, {
      view: {value: view, enumerable: true},
      sessionId: {value: sessionId, enumerable: true},
      socket: {value: socket, enumerable: true},
      userId: {value: data.userId || null, enumerable: true},
      data: {value: data, enumerable: true},
      release: {value: new foundry.config.ReleaseData(data.release), enumerable: true}
    });

    // Set up package data
    this.setupPackages(data);

    // Helper Properties
    Object.defineProperties(this, {
      audio: {value: new foundry.audio.AudioHelper(), enumerable: true},
      canvas: {value: new Canvas(), enumerable: true},
      clipboard: {value: new ClipboardHelper(), enumerable: true},
      collections: {value: new foundry.utils.Collection(), enumerable: true},
      compendiumArt: {value: new foundry.helpers.CompendiumArt(), enumerable: true},
      documentIndex: {value: new DocumentIndex(), enumerable: true},
      i18n: {value: new Localization(data?.options?.language), enumerable: true},
      issues: {value: new ClientIssues(), enumerable: true},
      gamepad: {value: new GamepadManager(), enumerable: true},
      keyboard: {value: new KeyboardManager(), enumerable: true},
      mouse: {value: new MouseManager(), enumerable: true},
      nue: {value: new NewUserExperience(), enumerable: true},
      packs: {value: new CompendiumPacks(), enumerable: true},
      settings: {value: new ClientSettings(data.settings || []), enumerable: true},
      time: {value: new GameTime(socket), enumerable: true},
      tooltip: {value: new TooltipManager(), configurable: true, enumerable: true},
      tours: {value: new Tours(), enumerable: true},
      video: {value: new VideoHelper(), enumerable: true},
      workers: {value: new WorkerManager(), enumerable: true},
      keybindings: {value: new ClientKeybindings(), enumerable: true}
    });

    /**
     * The singleton game Canvas.
     * @type {Canvas}
     */
    Object.defineProperty(globalThis, "canvas", {value: this.canvas, writable: true});
  }

  /* -------------------------------------------- */
  /*  Session Attributes                          */
  /* -------------------------------------------- */

  /**
   * The named view which is currently active.
   * @type {"join"|"setup"|"players"|"license"|"game"|"stream"}
   */
  view;

  /**
   * The object of world data passed from the server.
   * @type {object}
   */
  data;

  /**
   * The client session id which is currently active.
   * @type {string}
   */
  sessionId;

  /**
   * A reference to the open Socket.io connection.
   * @type {WebSocket|null}
   */
  socket;

  /**
   * The id of the active World user, if any.
   * @type {string|null}
   */
  userId;

  /* -------------------------------------------- */
  /*  Packages Attributes                         */
  /* -------------------------------------------- */

  /**
   * The game World which is currently active.
   * @type {World}
   */
  world;

  /**
   * The System which is used to power this game World.
   * @type {System}
   */
  system;

  /**
   * A Map of active Modules which are currently eligible to be enabled in this World.
   * The subset of Modules which are designated as active are currently enabled.
   * @type {Map<string, Module>}
   */
  modules;

  /**
   * A mapping of CompendiumCollection instances, one per Compendium pack.
   * @type {CompendiumPacks<string, CompendiumCollection>}
   */
  packs;

  /**
   * A registry of document sub-types and their respective data models.
   * @type {Record<string, Record<string, object>>}
   */
  get model() {
    return this.#model;
  }

  #model;

  /* -------------------------------------------- */
  /*  Document Attributes                         */
  /* -------------------------------------------- */

  /**
   * A registry of document types supported by the active world.
   * @type {Record<string, string[]>}
   */
  get documentTypes() {
    return this.#documentTypes;
  }

  #documentTypes;

  /**
   * The singleton DocumentIndex instance.
   * @type {DocumentIndex}
   */
  documentIndex;

  /**
   * The UUID redirects tree.
   * @type {foundry.utils.StringTree}
   */
  compendiumUUIDRedirects;

  /**
   * A mapping of WorldCollection instances, one per primary Document type.
   * @type {Collection<string, WorldCollection>}
   */
  collections;

  /**
   * The collection of Actor documents which exists in the World.
   * @type {Actors}
   */
  actors;

  /**
   * The collection of Cards documents which exists in the World.
   * @type {CardStacks}
   */
  cards;

  /**
   * The collection of Combat documents which exists in the World.
   * @type {CombatEncounters}
   */
  combats;

  /**
   * The collection of Cards documents which exists in the World.
   * @type {Folders}
   */
  folders;

  /**
   * The collection of Item documents which exists in the World.
   * @type {Items}
   */
  items;

  /**
   * The collection of JournalEntry documents which exists in the World.
   * @type {Journal}
   */
  journal;

  /**
   * The collection of Macro documents which exists in the World.
   * @type {Macros}
   */
  macros;

  /**
   * The collection of ChatMessage documents which exists in the World.
   * @type {Messages}
   */
  messages;

  /**
   * The collection of Playlist documents which exists in the World.
   * @type {Playlists}
   */
  playlists;

  /**
   * The collection of Scene documents which exists in the World.
   * @type {Scenes}
   */
  scenes;

  /**
   * The collection of RollTable documents which exists in the World.
   * @type {RollTables}
   */
  tables;

  /**
   * The collection of User documents which exists in the World.
   * @type {Users}
   */
  users;

  /* -------------------------------------------- */
  /*  State Attributes                            */
  /* -------------------------------------------- */

  /**
   * The Release data for this version of Foundry
   * @type {config.ReleaseData}
   */
  release;

  /**
   * Returns the current version of the Release, usable for comparisons using isNewerVersion
   * @type {string}
   */
  get version() {
    return this.release.version;
  }

  /**
   * Whether the Game is running in debug mode
   * @type {boolean}
   */
  debug = false;

  /**
   * A flag for whether texture assets for the game canvas are currently loading
   * @type {boolean}
   */
  loading = false;

  /**
   * The user role permissions setting.
   * @type {object}
   */
  permissions;

  /**
   * A flag for whether the Game has successfully reached the "ready" hook
   * @type {boolean}
   */
  ready = false;

  /**
   * An array of buffered events which are received by the socket before the game is ready to use that data.
   * Buffered events are replayed in the order they are received until the buffer is empty.
   * @type {Array<Readonly<[string, ...any]>>}
   */
  static #socketEventBuffer = [];

  /* -------------------------------------------- */
  /*  Helper Classes                              */
  /* -------------------------------------------- */

  /**
   * The singleton compendium art manager.
   * @type {CompendiumArt}
   */
  compendiumArt;

  /**
   * The singleton Audio Helper.
   * @type {AudioHelper}
   */
  audio;

  /**
   * The singleton game Canvas.
   * @type {Canvas}
   */
  canvas;

  /**
   * The singleton Clipboard Helper.
   * @type {ClipboardHelper}
   */
  clipboard;

  /**
   * Localization support.
   * @type {Localization}
   */
  i18n;

  /**
   * The singleton ClientIssues manager.
   * @type {ClientIssues}
   */
  issues;

  /**
   * The singleton Gamepad Manager.
   * @type {GamepadManager}
   */
  gamepad;

  /**
   * The singleton Keyboard Manager.
   * @type {KeyboardManager}
   */
  keyboard;

  /**
   * Client keybindings which are used to configure application behavior
   * @type {ClientKeybindings}
   */
  keybindings;

  /**
   * The singleton Mouse Manager.
   * @type {MouseManager}
   */
  mouse;

  /**
   * The singleton New User Experience manager.
   * @type {NewUserExperience}
   */
  nue;

  /**
   * Client settings which are used to configure application behavior.
   * @type {ClientSettings}
   */
  settings;

  /**
   * A singleton GameTime instance which manages the progression of time within the game world.
   * @type {GameTime}
   */
  time;

  /**
   * The singleton TooltipManager.
   * @type {TooltipManager}
   */
  tooltip;

  /**
   * The singleton Tours collection.
   * @type {Tours}
   */
  tours;

  /**
   * The singleton Video Helper.
   * @type {VideoHelper}
   */
  video;

  /**
   * A singleton web Worker manager.
   * @type {WorkerManager}
   */
  workers;

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

  /**
   * Fetch World data and return a Game instance
   * @param {string} view             The named view being created
   * @param {string|null} sessionId   The current sessionId of the connecting client
   * @returns {Promise<Game>}         A Promise which resolves to the created Game instance
   */
  static async create(view, sessionId) {
    const socket = sessionId ? await this.connect(sessionId) : null;
    const gameData = socket ? await this.getData(socket, view) : {};
    return new this(view, gameData, sessionId, socket);
  }

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

  /**
   * Establish a live connection to the game server through the socket.io URL
   * @param {string} sessionId  The client session ID with which to establish the connection
   * @returns {Promise<object>}  A promise which resolves to the connected socket, if successful
   */
  static async connect(sessionId) {

    // Connect to the websocket
    const socket = await new Promise((resolve, reject) => {
      const socket = io.connect({
        path: foundry.utils.getRoute("socket.io"),
        transports: ["websocket"],    // Require websocket transport instead of XHR polling
        upgrade: false,               // Prevent "upgrading" to websocket since it is enforced
        reconnection: true,           // Automatically reconnect
        reconnectionDelay: 500,       // Time before reconnection is attempted
        reconnectionAttempts: 10,     // Maximum reconnection attempts
        reconnectionDelayMax: 500,    // The maximum delay between reconnection attempts
        query: {session: sessionId},  // Pass session info
        cookie: false
      });

      // Confirm successful session creation
      socket.on("session", response => {
        socket.session = response;
        const id = response.sessionId;
        if ( !id || (sessionId && (sessionId !== id)) ) return foundry.utils.debouncedReload();
        console.log(`${vtt} | Connected to server socket using session ${id}`);
        resolve(socket);
      });

      // Fail to establish an initial connection
      socket.on("connectTimeout", () => {
        reject(new Error("Failed to establish a socket connection within allowed timeout."));
      });
      socket.on("connectError", err => reject(err));
    });

    // Buffer events until the game is ready
    socket.prependAny(Game.#bufferSocketEvents);

    // Disconnection and reconnection attempts
    let disconnectedTime = 0;
    socket.on("disconnect", () => {
      disconnectedTime = Date.now();
      ui.notifications.error("You have lost connection to the server, attempting to re-establish.");
    });

    // Reconnect attempt
    socket.io.on("reconnect_attempt", () => {
      const t = Date.now();
      console.log(`${vtt} | Attempting to re-connect: ${((t - disconnectedTime) / 1000).toFixed(2)} seconds`);
    });

    // Reconnect failed
    socket.io.on("reconnect_failed", () => {
      ui.notifications.error(`${vtt} | Server connection lost.`);
      window.location.href = foundry.utils.getRoute("no");
    });

    // Reconnect succeeded
    const reconnectTimeRequireRefresh = 5000;
    socket.io.on("reconnect", () => {
      ui.notifications.info(`${vtt} | Server connection re-established.`);
      if ( (Date.now() - disconnectedTime) >= reconnectTimeRequireRefresh ) {
        foundry.utils.debouncedReload();
      }
    });
    return socket;
  }

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

  /**
   * Place a buffered socket event into the queue
   * @param {[string, ...any]} args     Arguments of the socket event
   */
  static #bufferSocketEvents(...args) {
    Game.#socketEventBuffer.push(Object.freeze(args));
  }

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

  /**
   * Apply the queue of buffered socket events to game data once the game is ready.
   */
  static #applyBufferedSocketEvents() {
    while ( Game.#socketEventBuffer.length ) {
      const args = Game.#socketEventBuffer.shift();
      console.log(`Applying buffered socket event: ${args[0]}`);
      game.socket.emitEvent(args);
    }
  }

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

  /**
   * Retrieve the cookies which are attached to the client session
   * @returns {object}   The session cookies
   */
  static getCookies() {
    const cookies = {};
    for (let cookie of document.cookie.split("; ")) {
      let [name, value] = cookie.split("=");
      cookies[name] = decodeURIComponent(value);
    }
    return cookies;
  }

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

  /**
   * Request World data from server and return it
   * @param {Socket} socket     The active socket connection
   * @param {string} view       The view for which data is being requested
   * @returns {Promise<object>}
   */
  static async getData(socket, view) {
    if ( !socket.session.userId ) {
      socket.disconnect();
      window.location.href = foundry.utils.getRoute("join");
    }
    return new Promise(resolve => {
      socket.emit("world", resolve);
    });
  }

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

  /**
   * Get the current World status upon initial connection.
   * @param {Socket} socket  The active client socket connection
   * @returns {Promise<boolean>}
   */
  static async getWorldStatus(socket) {
    const status = await new Promise(resolve => {
      socket.emit("getWorldStatus", resolve);
    });
    console.log(`${vtt} | The game World is currently ${status ? "active" : "not active"}`);
    return status;
  }

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

  /**
   * Configure package data that is currently enabled for this world
   * @param {object} data  Game data provided by the server socket
   */
  setupPackages(data) {
    if ( data.world ) {
      this.world = new World(data.world);
    }
    if ( data.system ) {
      this.system = new System(data.system);
      this.#model = Object.freeze(data.model);
      this.#template = Object.freeze(data.template);
      this.#documentTypes = Object.freeze(Object.entries(this.model).reduce((obj, [d, types]) => {
        obj[d] = Object.keys(types);
        return obj;
      }, {}));
    }
    this.modules = new foundry.utils.Collection(data.modules.map(m => [m.id, new Module(m)]));
  }

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

  /**
   * Return the named scopes which can exist for packages.
   * Scopes are returned in the prioritization order that their content is loaded.
   * @returns {string[]}    An array of string package scopes
   */
  getPackageScopes() {
    return CONFIG.DatabaseBackend.getFlagScopes();
  }

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

  /**
   * Initialize the Game for the current window location
   */
  async initialize() {
    console.log(`${vtt} | Initializing Foundry Virtual Tabletop Game`);
    this.ready = false;

    Hooks.callAll("init");

    // Register game settings
    this.registerSettings();

    // Initialize language translations
    await this.i18n.initialize();

    // Register Tours
    await this.registerTours();

    // Activate event listeners
    this.activateListeners();

    // Initialize the current view
    await this._initializeView();

    // Display usability warnings or errors
    this.issues._detectUsabilityIssues();
  }

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

  /**
   * Shut down the currently active Game. Requires GameMaster user permission.
   * @returns {Promise<void>}
   */
  async shutDown() {
    if ( !(game.user?.isGM || game.data.isAdmin) ) {
      throw new Error("Only a Gamemaster User or server Administrator may shut down the currently active world");
    }

    // Display a warning if other players are connected
    const othersActive = game.users.filter(u => u.active && !u.isSelf).length;
    if ( othersActive ) {
      const warning = othersActive > 1 ? "GAME.ReturnSetupActiveUsers" : "GAME.ReturnSetupActiveUser";
      const confirm = await Dialog.confirm({
        title: game.i18n.localize("GAME.ReturnSetup"),
        content: `<p>${game.i18n.format(warning, {number: othersActive})}</p>`
      });
      if ( !confirm ) return;
    }

    // Dispatch the request
    const setupUrl = foundry.utils.getRoute("setup");
    const response = await foundry.utils.fetchWithTimeout(setupUrl, {
      method: "POST",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify({shutdown: true}),
      redirect: "manual"
    });

    // Redirect after allowing time for a pop-up notification
    setTimeout(() => window.location.href = response.url, 1000);
  }

  /* -------------------------------------------- */
  /*  Primary Game Initialization
  /* -------------------------------------------- */

  /**
   * Fully set up the game state, initializing Documents, UI applications, and the Canvas
   * @returns {Promise<void>}
   */
  async setupGame() {

    // Store permission settings
    this.permissions = await this.settings.get("core", "permissions");

    // Initialize configuration data
    this.initializeConfig();

    // Initialize world data
    this.initializePacks();             // Initialize compendium packs
    this.initializeDocuments();         // Initialize world documents

    // Monkeypatch a search method on EmbeddedCollection
    foundry.abstract.EmbeddedCollection.prototype.search = DocumentCollection.prototype.search;

    // Call world setup hook
    Hooks.callAll("setup");

    // Initialize audio playback
    // noinspection ES6MissingAwait
    this.playlists.initialize();

    // Initialize AV conferencing
    // noinspection ES6MissingAwait
    this.initializeRTC();

    // Initialize user interface
    this.initializeMouse();
    this.initializeGamepads();
    this.initializeKeyboard();

    // Parse the UUID redirects configuration.
    this.#parseRedirects();

    // Initialize dynamic token config
    foundry.canvas.tokens.TokenRingConfig.initialize();

    // Call this here to set up a promise that dependent UI elements can await.
    this.canvas.initializing = this.initializeCanvas();
    this.initializeUI();
    DocumentSheetConfig.initializeSheets();

    // If the player is not a GM and does not have an impersonated character, prompt for selection
    if ( !this.user.isGM && !this.user.character ) {
      this.user.sheet.render(true);
    }

    // Index documents for search
    await this.documentIndex.index();

    // Wait for canvas initialization and call all game ready hooks
    await this.canvas.initializing;
    this.ready = true;
    this.activateSocketListeners();
    Hooks.callAll("ready");

    // Initialize New User Experience
    this.nue.initialize();
  }

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

  /**
   * Initialize configuration state.
   */
  initializeConfig() {
    // Configure token ring subject paths
    Object.assign(CONFIG.Token.ring.subjectPaths, this.system.flags?.tokenRingSubjectMappings);
    for ( const module of this.modules ) {
      if ( module.active ) Object.assign(CONFIG.Token.ring.subjectPaths, module.flags?.tokenRingSubjectMappings);
    }

    // Configure Actor art.
    this.compendiumArt._registerArt();
  }

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

  /**
   * Initialize game state data by creating WorldCollection instances for every primary Document type
   */
  initializeDocuments() {
    const excluded = ["FogExploration", "Setting"];
    const initOrder = ["User", "Folder", "Actor", "Item", "Scene", "Combat", "JournalEntry", "Macro", "Playlist",
      "RollTable", "Cards", "ChatMessage"];
    if ( !new Set(initOrder).equals(new Set(CONST.WORLD_DOCUMENT_TYPES.filter(t => !excluded.includes(t)))) ) {
      throw new Error("Missing Document initialization type!");
    }

    // Warn developers about collision with V10 DataModel changes
    const v10DocumentMigrationErrors = [];
    for ( const documentName of initOrder ) {
      const cls = getDocumentClass(documentName);
      for ( const key of cls.schema.keys() ) {
        if ( key in cls.prototype ) {
          const err = `The ${cls.name} class defines the "${key}" attribute which collides with the "${key}" key in `
          + `the ${cls.documentName} data schema`;
          v10DocumentMigrationErrors.push(err);
        }
      }
    }
    if ( v10DocumentMigrationErrors.length ) {
      v10DocumentMigrationErrors.unshift("Version 10 Compatibility Failure",
        "-".repeat(90),
        "Several Document class definitions include properties which collide with the new V10 DataModel:",
        "-".repeat(90));
      throw new Error(v10DocumentMigrationErrors.join("\n"));
    }

    // Initialize world document collections
    this._documentsReady = false;
    const t0 = performance.now();
    for ( let documentName of initOrder ) {
      const documentClass = CONFIG[documentName].documentClass;
      const collectionClass = CONFIG[documentName].collection;
      const collectionName = documentClass.metadata.collection;
      this[collectionName] = new collectionClass(this.data[collectionName]);
      this.collections.set(documentName, this[collectionName]);
    }
    this._documentsReady = true;

    // Prepare data for all world documents (this was skipped at construction-time)
    for ( const collection of this.collections.values() ) {
      for ( let document of collection ) {
        document._safePrepareData();
      }
    }

    // Special-case - world settings
    this.collections.set("Setting", this.settings.storage.get("world"));

    // Special case - fog explorations
    const fogCollectionCls = CONFIG.FogExploration.collection;
    this.collections.set("FogExploration", new fogCollectionCls());
    const dt = performance.now() - t0;
    console.debug(`${vtt} | Prepared World Documents in ${Math.round(dt)}ms`);
  }

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

  /**
   * Initialize the Compendium packs which are present within this Game
   * Create a Collection which maps each Compendium pack using it's collection ID
   * @returns {Collection<string,CompendiumCollection>}
   */
  initializePacks() {
    for ( let metadata of this.data.packs ) {
      let pack = this.packs.get(metadata.id);

      // Update the compendium collection
      if ( !pack ) pack = new CompendiumCollection(metadata);
      this.packs.set(pack.collection, pack);

      // Re-render any applications associated with pack content
      for ( let document of pack.contents ) {
        document.render(false, {editable: !pack.locked});
      }

      // Re-render any open Compendium applications
      pack.apps.forEach(app => app.render(false));
    }
    return this.packs;
  }

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

  /**
   * Initialize the WebRTC implementation
   */
  initializeRTC() {
    this.webrtc = new AVMaster();
    return this.webrtc.connect();
  }

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

  /**
   * Initialize core UI elements
   */
  initializeUI() {

    // Global light/dark theme.
    matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => this.#updatePreferredColorScheme());
    this.#updatePreferredColorScheme();

    // Initialize all singleton applications
    for ( let [k, cls] of Object.entries(CONFIG.ui) ) {
      ui[k] = new cls();
    }

    // Initialize pack applications
    for ( let pack of this.packs.values() ) {
      if ( Application.isPrototypeOf(pack.applicationClass) ) {
        const app = new pack.applicationClass({collection: pack});
        pack.apps.push(app);
      }
    }

    // Render some applications (asynchronously)
    ui.nav.render(true);
    ui.notifications.render(true);
    ui.sidebar.render(true);
    ui.players.render(true);
    ui.hotbar.render(true);
    ui.webrtc.render(true);
    ui.pause.render(true);
    ui.controls.render(true);
    this.scaleFonts();
  }

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

  /**
   * Initialize the game Canvas
   * @returns {Promise<void>}
   */
  async initializeCanvas() {

    // Ensure that necessary fonts have fully loaded
    await FontConfig._loadFonts();

    // Identify the current scene
    const scene = game.scenes.current;

    // Attempt to initialize the canvas and draw the current scene
    try {
      this.canvas.initialize();
      if ( scene ) await scene.view();
      else if ( this.canvas.initialized ) await this.canvas.draw(null);
    } catch(err) {
      Hooks.onError("Game#initializeCanvas", err, {
        msg: "Failed to render WebGL canvas",
        log: "error"
      });
    }
  }

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

  /**
   * Initialize Keyboard controls
   */
  initializeKeyboard() {
    Object.defineProperty(globalThis, "keyboard", {value: this.keyboard, writable: false, enumerable: true});
    this.keyboard._activateListeners();
    try {
      game.keybindings._registerCoreKeybindings(this.view);
      game.keybindings.initialize();
    }
    catch(e) {
      console.error(e);
    }
  }

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

  /**
   * Initialize Mouse controls
   */
  initializeMouse() {
    this.mouse._activateListeners();
  }

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

  /**
   * Initialize Gamepad controls
   */
  initializeGamepads() {
    this.gamepad._activateListeners();
  }

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

  /**
   * Register core game settings
   */
  registerSettings() {

    // Permissions Control Menu
    game.settings.registerMenu("core", "permissions", {
      name: "PERMISSION.Configure",
      label: "PERMISSION.ConfigureLabel",
      hint: "PERMISSION.ConfigureHint",
      icon: "fas fa-user-lock",
      type: foundry.applications.apps.PermissionConfig,
      restricted: true
    });

    // User Role Permissions
    game.settings.register("core", "permissions", {
      name: "Permissions",
      scope: "world",
      default: {},
      type: Object,
      config: false,
      onChange: permissions => {
        game.permissions = permissions;
        if ( ui.controls ) ui.controls.initialize();
        if ( ui.sidebar ) ui.sidebar.render();
        if ( canvas.ready ) canvas.controls.drawCursors();
      }
    });

    // WebRTC Control Menu
    game.settings.registerMenu("core", "webrtc", {
      name: "WEBRTC.Title",
      label: "WEBRTC.MenuLabel",
      hint: "WEBRTC.MenuHint",
      icon: "fas fa-headset",
      type: AVConfig,
      restricted: false
    });

    // RTC World Settings
    game.settings.register("core", "rtcWorldSettings", {
      name: "WebRTC (Audio/Video Conferencing) World Settings",
      scope: "world",
      default: AVSettings.DEFAULT_WORLD_SETTINGS,
      type: Object,
      onChange: () => game.webrtc.settings.changed()
    });

    // RTC Client Settings
    game.settings.register("core", "rtcClientSettings", {
      name: "WebRTC (Audio/Video Conferencing) Client specific Configuration",
      scope: "client",
      default: AVSettings.DEFAULT_CLIENT_SETTINGS,
      type: Object,
      onChange: () => game.webrtc.settings.changed()
    });

    // Default Token Configuration
    game.settings.registerMenu("core", DefaultTokenConfig.SETTING, {
      name: "SETTINGS.DefaultTokenN",
      label: "SETTINGS.DefaultTokenL",
      hint: "SETTINGS.DefaultTokenH",
      icon: "fas fa-user-alt",
      type: DefaultTokenConfig,
      restricted: true
    });

    // Default Token Settings
    game.settings.register("core", DefaultTokenConfig.SETTING, {
      name: "SETTINGS.DefaultTokenN",
      hint: "SETTINGS.DefaultTokenL",
      scope: "world",
      type: Object,
      default: {},
      requiresReload: true
    });

    // Font Configuration
    game.settings.registerMenu("core", FontConfig.SETTING, {
      name: "SETTINGS.FontConfigN",
      label: "SETTINGS.FontConfigL",
      hint: "SETTINGS.FontConfigH",
      icon: "fa-solid fa-font",
      type: FontConfig,
      restricted: true
    });

    // Font Configuration Settings
    game.settings.register("core", FontConfig.SETTING, {
      scope: "world",
      type: Object,
      default: {}
    });

    // Combat Tracker Configuration
    game.settings.registerMenu("core", Combat.CONFIG_SETTING, {
      name: "SETTINGS.CombatConfigN",
      label: "SETTINGS.CombatConfigL",
      hint: "SETTINGS.CombatConfigH",
      icon: "fa-solid fa-swords",
      type: CombatTrackerConfig
    });

    // No-Canvas Mode
    game.settings.register("core", "noCanvas", {
      name: "SETTINGS.NoCanvasN",
      hint: "SETTINGS.NoCanvasL",
      scope: "client",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: false}),
      requiresReload: true
    });

    // Language preference
    game.settings.register("core", "language", {
      name: "SETTINGS.LangN",
      hint: "SETTINGS.LangL",
      scope: "client",
      config: true,
      type: new foundry.data.fields.StringField({required: true, blank: false, initial: game.i18n.lang,
        choices: CONFIG.supportedLanguages}),
      requiresReload: true
    });

    // Color Scheme
    game.settings.register("core", "colorScheme", {
      name: "SETTINGS.ColorSchemeN",
      hint: "SETTINGS.ColorSchemeH",
      scope: "client",
      config: true,
      type: new foundry.data.fields.StringField({required: true, blank: true, initial: "", choices: {
        "": "SETTINGS.ColorSchemeDefault",
        dark: "SETTINGS.ColorSchemeDark",
        light: "SETTINGS.ColorSchemeLight"
      }}),
      onChange: () => this.#updatePreferredColorScheme()
    });

    // Token ring settings
    foundry.canvas.tokens.TokenRingConfig.registerSettings();

    // Chat message roll mode
    game.settings.register("core", "rollMode", {
      name: "Default Roll Mode",
      scope: "client",
      config: false,
      type: new foundry.data.fields.StringField({required: true, blank: false, initial: CONST.DICE_ROLL_MODES.PUBLIC,
        choices: CONFIG.Dice.rollModes}),
      onChange: ChatLog._setRollMode
    });

    // Dice Configuration
    game.settings.register("core", "diceConfiguration", {
      config: false,
      default: {},
      type: Object,
      scope: "client"
    });

    game.settings.registerMenu("core", "diceConfiguration", {
      name: "DICE.CONFIG.Title",
      label: "DICE.CONFIG.Label",
      hint: "DICE.CONFIG.Hint",
      icon: "fas fa-dice-d20",
      type: DiceConfig,
      restricted: false
    });

    // Compendium art configuration.
    game.settings.register("core", this.compendiumArt.SETTING, {
      config: false,
      default: {},
      type: Object,
      scope: "world"
    });

    game.settings.registerMenu("core", this.compendiumArt.SETTING, {
      name: "COMPENDIUM.ART.SETTING.Title",
      label: "COMPENDIUM.ART.SETTING.Label",
      hint: "COMPENDIUM.ART.SETTING.Hint",
      icon: "fas fa-palette",
      type: foundry.applications.apps.CompendiumArtConfig,
      restricted: true
    });

    // World time
    game.settings.register("core", "time", {
      name: "World Time",
      scope: "world",
      config: false,
      type: new foundry.data.fields.NumberField({required: true, nullable: false, initial: 0}),
      onChange: this.time.onUpdateWorldTime.bind(this.time)
    });

    // Register module configuration settings
    game.settings.register("core", ModuleManagement.CONFIG_SETTING, {
      name: "Module Configuration Settings",
      scope: "world",
      config: false,
      default: {},
      type: Object,
      requiresReload: true
    });

    // Register compendium visibility setting
    game.settings.register("core", CompendiumCollection.CONFIG_SETTING, {
      name: "Compendium Configuration",
      scope: "world",
      config: false,
      default: {},
      type: Object,
      onChange: () => {
        this.initializePacks();
        ui.compendium.render();
      }
    });

    // Combat Tracker Configuration
    game.settings.register("core", Combat.CONFIG_SETTING, {
      name: "Combat Tracker Configuration",
      scope: "world",
      config: false,
      default: {},
      type: Object,
      onChange: () => {
        if (game.combat) {
          game.combat.reset();
          game.combats.render();
        }
      }
    });

    // Document Sheet Class Configuration
    game.settings.register("core", "sheetClasses", {
      name: "Sheet Class Configuration",
      scope: "world",
      config: false,
      default: {},
      type: Object,
      onChange: setting => DocumentSheetConfig.updateDefaultSheets(setting)
    });

    game.settings.registerMenu("core", "sheetClasses", {
      name: "SETTINGS.DefaultSheetsN",
      label: "SETTINGS.DefaultSheetsL",
      hint: "SETTINGS.DefaultSheetsH",
      icon: "fa-solid fa-scroll",
      type: DefaultSheetsConfig,
      restricted: true
    });

    // Are Chat Bubbles Enabled?
    game.settings.register("core", "chatBubbles", {
      name: "SETTINGS.CBubN",
      hint: "SETTINGS.CBubL",
      scope: "client",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: true})
    });

    // Pan to Token Speaker
    game.settings.register("core", "chatBubblesPan", {
      name: "SETTINGS.CBubPN",
      hint: "SETTINGS.CBubPL",
      scope: "client",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: true})
    });

    // Scrolling Status Text
    game.settings.register("core", "scrollingStatusText", {
      name: "SETTINGS.ScrollStatusN",
      hint: "SETTINGS.ScrollStatusL",
      scope: "world",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: true})
    });

    // Disable Resolution Scaling
    game.settings.register("core", "pixelRatioResolutionScaling", {
      name: "SETTINGS.ResolutionScaleN",
      hint: "SETTINGS.ResolutionScaleL",
      scope: "client",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: true}),
      requiresReload: true
    });

    // Left-Click Deselection
    game.settings.register("core", "leftClickRelease", {
      name: "SETTINGS.LClickReleaseN",
      hint: "SETTINGS.LClickReleaseL",
      scope: "client",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: false})
    });

    // Canvas Performance Mode
    game.settings.register("core", "performanceMode", {
      name: "SETTINGS.PerformanceModeN",
      hint: "SETTINGS.PerformanceModeL",
      scope: "client",
      config: true,
      type: new foundry.data.fields.NumberField({required: true, nullable: true, initial: null, choices: {
        [CONST.CANVAS_PERFORMANCE_MODES.LOW]: "SETTINGS.PerformanceModeLow",
        [CONST.CANVAS_PERFORMANCE_MODES.MED]: "SETTINGS.PerformanceModeMed",
        [CONST.CANVAS_PERFORMANCE_MODES.HIGH]: "SETTINGS.PerformanceModeHigh",
        [CONST.CANVAS_PERFORMANCE_MODES.MAX]: "SETTINGS.PerformanceModeMax"
      }}),
      requiresReload: true,
      onChange: () => {
        canvas._configurePerformanceMode();
        return canvas.ready ? canvas.draw() : null;
      }
    });

    // Maximum Framerate
    game.settings.register("core", "maxFPS", {
      name: "SETTINGS.MaxFPSN",
      hint: "SETTINGS.MaxFPSL",
      scope: "client",
      config: true,
      type: new foundry.data.fields.NumberField({required: true, min: 10, max: 60, step: 10, initial: 60}),
      onChange: () => {
        canvas._configurePerformanceMode();
        return canvas.ready ? canvas.draw() : null;
      }
    });

    // FPS Meter
    game.settings.register("core", "fpsMeter", {
      name: "SETTINGS.FPSMeterN",
      hint: "SETTINGS.FPSMeterL",
      scope: "client",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: false}),
      onChange: enabled => {
        if ( enabled ) return canvas.activateFPSMeter();
        else return canvas.deactivateFPSMeter();
      }
    });

    // Font scale
    game.settings.register("core", "fontSize", {
      name: "SETTINGS.FontSizeN",
      hint: "SETTINGS.FontSizeL",
      scope: "client",
      config: true,
      type: new foundry.data.fields.NumberField({required: true, min: 1, max: 10, step: 1, initial: 5}),
      onChange: () => game.scaleFonts()
    });

    // Photosensitivity mode.
    game.settings.register("core", "photosensitiveMode", {
      name: "SETTINGS.PhotosensitiveModeN",
      hint: "SETTINGS.PhotosensitiveModeL",
      scope: "client",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: false}),
      requiresReload: true
    });

    // Live Token Drag Preview
    game.settings.register("core", "tokenDragPreview", {
      name: "SETTINGS.TokenDragPreviewN",
      hint: "SETTINGS.TokenDragPreviewL",
      scope: "world",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: false})
    });

    // Animated Token Vision
    game.settings.register("core", "visionAnimation", {
      name: "SETTINGS.AnimVisionN",
      hint: "SETTINGS.AnimVisionL",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: true})
    });

    // Light Source Flicker
    game.settings.register("core", "lightAnimation", {
      name: "SETTINGS.AnimLightN",
      hint: "SETTINGS.AnimLightL",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: true}),
      onChange: () => canvas.effects?.activateAnimation()
    });

    // Mipmap Antialiasing
    game.settings.register("core", "mipmap", {
      name: "SETTINGS.MipMapN",
      hint: "SETTINGS.MipMapL",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: true}),
      onChange: () => canvas.ready ? canvas.draw() : null
    });

    // Default Drawing Configuration
    game.settings.register("core", DrawingsLayer.DEFAULT_CONFIG_SETTING, {
      name: "Default Drawing Configuration",
      scope: "client",
      config: false,
      default: {},
      type: Object
    });

    // Keybindings
    game.settings.register("core", "keybindings", {
      scope: "client",
      config: false,
      type: Object,
      default: {},
      onChange: () => game.keybindings.initialize()
    });

    // New User Experience
    game.settings.register("core", "nue.shownTips", {
      scope: "world",
      type: new foundry.data.fields.BooleanField({initial: false}),
      config: false
    });

    // Tours
    game.settings.register("core", "tourProgress", {
      scope: "client",
      config: false,
      type: Object,
      default: {}
    });

    // Editor autosave.
    game.settings.register("core", "editorAutosaveSecs", {
      name: "SETTINGS.EditorAutosaveN",
      hint: "SETTINGS.EditorAutosaveH",
      scope: "world",
      config: true,
      type: new foundry.data.fields.NumberField({required: true, min: 30, max: 300, step: 10, initial: 60})
    });

    // Link recommendations.
    game.settings.register("core", "pmHighlightDocumentMatches", {
      name: "SETTINGS.EnableHighlightDocumentMatches",
      hint: "SETTINGS.EnableHighlightDocumentMatchesH",
      scope: "world",
      config: false,
      type: new foundry.data.fields.BooleanField({initial: true})
    });

    // Combat Theme
    game.settings.register("core", "combatTheme", {
      name: "SETTINGS.CombatThemeN",
      hint: "SETTINGS.CombatThemeL",
      scope: "client",
      config: false,
      type: new foundry.data.fields.StringField({required: true, blank: false, initial: "none",
        choices: () => Object.entries(CONFIG.Combat.sounds).reduce((choices, s) => {
          choices[s[0]] = game.i18n.localize(s[1].label);
          return choices;
        }, {none: game.i18n.localize("SETTINGS.None")})
      })
    });

    // Show Toolclips
    game.settings.register("core", "showToolclips", {
      name: "SETTINGS.ShowToolclips",
      hint: "SETTINGS.ShowToolclipsH",
      scope: "client",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: true}),
      requiresReload: true
    });

    // Favorite paths
    game.settings.register("core", "favoritePaths", {
      scope: "client",
      config: false,
      type: Object,
      default: {"data-/": {source: "data", path: "/", label: "root"}}
    });

    // Top level collection sorting
    game.settings.register("core", "collectionSortingModes", {
      scope: "client",
      config: false,
      type: Object,
      default: {}
    });

    // Collection searching
    game.settings.register("core", "collectionSearchModes", {
      scope: "client",
      config: false,
      type: Object,
      default: {}
    });

    // Hotbar lock
    game.settings.register("core", "hotbarLock", {
      scope: "client",
      config: false,
      type: new foundry.data.fields.BooleanField({initial: false})
    });

    // Adventure imports
    game.settings.register("core", "adventureImports", {
      scope: "world",
      config: false,
      type: Object,
      default: {}
    });

    // Document-specific settings
    RollTables.registerSettings();

    // Audio playback settings
    foundry.audio.AudioHelper.registerSettings();

    // Register CanvasLayer settings
    NotesLayer.registerSettings();

    // Square Grid Diagonals
    game.settings.register("core", "gridDiagonals", {
      name: "SETTINGS.GridDiagonalsN",
      hint: "SETTINGS.GridDiagonalsL",
      scope: "world",
      config: true,
      type: new foundry.data.fields.NumberField({
        required: true,
        initial: game.system?.grid.diagonals ?? CONST.GRID_DIAGONALS.EQUIDISTANT,
        choices: {
          [CONST.GRID_DIAGONALS.EQUIDISTANT]: "SETTINGS.GridDiagonalsEquidistant",
          [CONST.GRID_DIAGONALS.EXACT]: "SETTINGS.GridDiagonalsExact",
          [CONST.GRID_DIAGONALS.APPROXIMATE]: "SETTINGS.GridDiagonalsApproximate",
          [CONST.GRID_DIAGONALS.RECTILINEAR]: "SETTINGS.GridDiagonalsRectilinear",
          [CONST.GRID_DIAGONALS.ALTERNATING_1]: "SETTINGS.GridDiagonalsAlternating1",
          [CONST.GRID_DIAGONALS.ALTERNATING_2]: "SETTINGS.GridDiagonalsAlternating2",
          [CONST.GRID_DIAGONALS.ILLEGAL]: "SETTINGS.GridDiagonalsIllegal"
        }
      }),
      requiresReload: true
    });

    TemplateLayer.registerSettings();
  }

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

  /**
   * Register core Tours
   * @returns {Promise<void>}
   */
  async registerTours() {
    try {
      game.tours.register("core", "welcome", await SidebarTour.fromJSON("/tours/welcome.json"));
      game.tours.register("core", "installingASystem", await SetupTour.fromJSON("/tours/installing-a-system.json"));
      game.tours.register("core", "creatingAWorld", await SetupTour.fromJSON("/tours/creating-a-world.json"));
      game.tours.register("core", "backupsOverview", await SetupTour.fromJSON("/tours/backups-overview.json"));
      game.tours.register("core", "compatOverview", await SetupTour.fromJSON("/tours/compatibility-preview-overview.json"));
      game.tours.register("core", "uiOverview", await Tour.fromJSON("/tours/ui-overview.json"));
      game.tours.register("core", "sidebar", await SidebarTour.fromJSON("/tours/sidebar.json"));
      game.tours.register("core", "canvasControls", await CanvasTour.fromJSON("/tours/canvas-controls.json"));
    }
    catch(err) {
      console.error(err);
    }
  }

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

  /**
   * Is the current session user authenticated as an application administrator?
   * @type {boolean}
   */
  get isAdmin() {
    return this.data.isAdmin;
  }

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

  /**
   * The currently connected User document, or null if Users is not yet initialized
   * @type {User|null}
   */
  get user() {
    return this.users ? this.users.current : null;
  }

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

  /**
   * A convenience accessor for the currently viewed Combat encounter
   * @type {Combat}
   */
  get combat() {
    return this.combats?.viewed;
  }

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

  /**
   * A state variable which tracks whether the game session is currently paused
   * @type {boolean}
   */
  get paused() {
    return this.data.paused;
  }

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

  /**
   * A convenient reference to the currently active canvas tool
   * @type {string}
   */
  get activeTool() {
    return ui.controls?.activeTool ?? "select";
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Toggle the pause state of the game
   * Trigger the `pauseGame` Hook when the paused state changes
   * @param {boolean} pause         The desired pause state; true for paused, false for un-paused
   * @param {boolean} [push=false]  Push the pause state change to other connected clients? Requires an GM user.
   * @returns {boolean}             The new paused state
   */
  togglePause(pause, push=false) {
    this.data.paused = pause ?? !this.data.paused;
    if (push && game.user.isGM) game.socket.emit("pause", this.data.paused);
    ui.pause.render();
    Hooks.callAll("pauseGame", this.data.paused);
    return this.data.paused;
  }

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

  /**
   * Open Character sheet for current token or controlled actor
   * @returns {ActorSheet|null}  The ActorSheet which was toggled, or null if the User has no character
   */
  toggleCharacterSheet() {
    const token = canvas.ready && (canvas.tokens.controlled.length === 1) ? canvas.tokens.controlled[0] : null;
    const actor = token ? token.actor : game.user.character;
    if ( !actor ) return null;
    const sheet = actor.sheet;
    if ( sheet.rendered ) {
      if ( sheet._minimized ) sheet.maximize();
      else sheet.close();
    }
    else sheet.render(true);
    return sheet;
  }

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

  /**
   * Log out of the game session by returning to the Join screen
   */
  logOut() {
    if ( this.socket ) this.socket.disconnect();
    window.location.href = foundry.utils.getRoute("join");
  }

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

  /**
   * Scale the base font size according to the user's settings.
   * @param {number} [index]  Optionally supply a font size index to use, otherwise use the user's setting.
   *                          Available font sizes, starting at index 1, are: 8, 10, 12, 14, 16, 18, 20, 24, 28, and 32.
   */
  scaleFonts(index) {
    const fontSizes = [8, 10, 12, 14, 16, 18, 20, 24, 28, 32];
    index = index ?? game.settings.get("core", "fontSize");
    const size = fontSizes[index - 1] || 16;
    document.documentElement.style.fontSize = `${size}px`;
  }

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

  /**
   * Set the global CSS theme according to the user's preferred color scheme settings.
   */
  #updatePreferredColorScheme() {

    // Light or Dark Theme
    let theme;
    const clientSetting = game.settings.get("core", "colorScheme");
    if ( clientSetting ) theme = `theme-${clientSetting}`;
    else if ( matchMedia("(prefers-color-scheme: dark)").matches ) theme = "theme-dark";
    else if ( matchMedia("(prefers-color-scheme: light)").matches ) theme = "theme-light";
    document.body.classList.remove("theme-light", "theme-dark");
    if ( theme ) document.body.classList.add(theme);

    // User Color
    for ( const user of game.users ) {
      document.documentElement.style.setProperty(`--user-color-${user.id}`, user.color.css);
    }
    document.documentElement.style.setProperty("--user-color", game.user.color.css);

  }

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

  /**
   * Parse the configured UUID redirects and arrange them as a {@link foundry.utils.StringTree}.
   */
  #parseRedirects() {
    this.compendiumUUIDRedirects = new foundry.utils.StringTree();
    for ( const [prefix, replacement] of Object.entries(CONFIG.compendium.uuidRedirects) ) {
      if ( !prefix.startsWith("Compendium.") ) continue;
      this.compendiumUUIDRedirects.addLeaf(prefix.split("."), replacement.split("."));
    }
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * Activate Socket event listeners which are used to transact game state data with the server
   */
  activateSocketListeners() {

    // Stop buffering events
    game.socket.offAny(Game.#bufferSocketEvents);

    // Game pause
    this.socket.on("pause", pause => {
      game.togglePause(pause, false);
    });

    // Game shutdown
    this.socket.on("shutdown", () => {
      ui.notifications.info("The game world is shutting down and you will be returned to the server homepage.", {
        permanent: true
      });
      setTimeout(() => window.location.href = foundry.utils.getRoute("/"), 1000);
    });

    // Application reload.
    this.socket.on("reload", () => foundry.utils.debouncedReload());

    // Hot Reload
    this.socket.on("hotReload", this.#handleHotReload.bind(this));

    // Database Operations
    CONFIG.DatabaseBackend.activateSocketListeners(this.socket);

    // Additional events
    foundry.audio.AudioHelper._activateSocketListeners(this.socket);
    Users._activateSocketListeners(this.socket);
    Scenes._activateSocketListeners(this.socket);
    Journal._activateSocketListeners(this.socket);
    FogExplorations._activateSocketListeners(this.socket);
    ChatBubbles._activateSocketListeners(this.socket);
    ProseMirrorEditor._activateSocketListeners(this.socket);
    CompendiumCollection._activateSocketListeners(this.socket);
    RegionDocument._activateSocketListeners(this.socket);
    foundry.data.regionBehaviors.TeleportTokenRegionBehaviorType._activateSocketListeners(this.socket);

    // Apply buffered events
    Game.#applyBufferedSocketEvents();

    // Request updated activity data
    game.socket.emit("getUserActivity");
  }

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

  /**
   * @typedef {Object} HotReloadData
   * @property {string} packageType       The type of package which was modified
   * @property {string} packageId         The id of the package which was modified
   * @property {string} content           The updated stringified file content
   * @property {string} path              The relative file path which was modified
   * @property {string} extension         The file extension which was modified, e.g. "js", "css", "html"
   */

  /**
   * Handle a hot reload request from the server
   * @param {HotReloadData} data          The hot reload data
   * @private
   */
  #handleHotReload(data) {
    const proceed = Hooks.call("hotReload", data);
    if ( proceed === false ) return;

    switch ( data.extension ) {
      case "css": return this.#hotReloadCSS(data);
      case "html":
      case "hbs": return this.#hotReloadHTML(data);
      case "json": return this.#hotReloadJSON(data);
    }
  }

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

  /**
   * Handle hot reloading of CSS files
   * @param {HotReloadData} data          The hot reload data
   */
  #hotReloadCSS(data) {
    const links = document.querySelectorAll("link");
    const link = Array.from(links).find(l => {
      let href = l.getAttribute("href");
      if ( href.includes("?") ) {
        const [path, _query] = href.split("?");
        href = path;
      }
      return href === data.path;
    });
    if ( !link ) return;
    const href = link.getAttribute("href");
    link.setAttribute("href", `${href}?${Date.now()}`);
  }

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

  /**
   * Handle hot reloading of HTML files, such as Handlebars templates
   * @param {HotReloadData} data          The hot reload data
   */
  #hotReloadHTML(data) {
    let template;
    try {
      template = Handlebars.compile(data.content);
    }
    catch(err) {
      return console.error(err);
    }
    Handlebars.registerPartial(data.path, template);
    for ( const appV1 of Object.values(ui.windows) ) appV1.render();
    for ( const appV2 of foundry.applications.instances.values() ) appV2.render();
  }

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

  /**
   * Handle hot reloading of JSON files, such as language files
   * @param {HotReloadData} data          The hot reload data
   */
  #hotReloadJSON(data) {
    const currentLang = game.i18n.lang;
    if ( data.packageId === "core" ) {
      if ( !data.path.endsWith(`lang/${currentLang}.json`) ) return;
    }
    else {
      const pkg = data.packageType === "system" ? game.system : game.modules.get(data.packageId);
      const lang = pkg.languages.find(l=> (l.path === data.path) && (l.lang === currentLang));
      if ( !lang ) return;
    }

    // Update the translations
    let translations = {};
    try {
      translations = JSON.parse(data.content);
    }
    catch(err) {
      return console.error(err);
    }
    foundry.utils.mergeObject(game.i18n.translations, translations);
    for ( const appV1 of Object.values(ui.windows) ) appV1.render();
    for ( const appV2 of foundry.applications.instances.values() ) appV2.render();
  }

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

  /**
   * Activate Event Listeners which apply to every Game View
   */
  activateListeners() {

    // Disable touch zoom
    document.addEventListener("touchmove", ev => {
      if ( (ev.scale !== undefined) && (ev.scale !== 1) ) ev.preventDefault();
    }, {passive: false});

    // Disable right-click
    document.addEventListener("contextmenu", ev => ev.preventDefault());

    // Disable mouse 3, 4, and 5
    document.addEventListener("pointerdown", this._onPointerDown);
    document.addEventListener("pointerup", this._onPointerUp);

    // Prevent dragging and dropping unless a more specific handler allows it
    document.addEventListener("dragstart", this._onPreventDragstart);
    document.addEventListener("dragover", this._onPreventDragover);
    document.addEventListener("drop", this._onPreventDrop);

    // Support mousewheel interaction for range input elements
    window.addEventListener("wheel", Game._handleMouseWheelInputChange, {passive: false});

    // Tooltip rendering
    this.tooltip.activateEventListeners();

    // Document links
    TextEditor.activateListeners();

    // Await gestures to begin audio and video playback
    game.video.awaitFirstGesture();

    // Handle changes to the state of the browser window
    window.addEventListener("beforeunload", this._onWindowBeforeUnload);
    window.addEventListener("blur", this._onWindowBlur);
    window.addEventListener("resize", this._onWindowResize);
    if ( this.view === "game" ) {
      history.pushState(null, null, location.href);
      window.addEventListener("popstate", this._onWindowPopState);
    }

    // Force hyperlinks to a separate window/tab
    document.addEventListener("click", this._onClickHyperlink);
  }

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

  /**
   * Support mousewheel control for range type input elements
   * @param {WheelEvent} event    A Mouse Wheel scroll event
   * @private
   */
  static _handleMouseWheelInputChange(event) {
    const r = event.target;
    if ( (r.tagName !== "INPUT") || (r.type !== "range") || r.disabled || r.readOnly ) return;
    event.preventDefault();
    event.stopPropagation();

    // Adjust the range slider by the step size
    const step = (parseFloat(r.step) || 1.0) * Math.sign(-1 * event.deltaY);
    r.value = Math.clamp(parseFloat(r.value) + step, parseFloat(r.min), parseFloat(r.max));

    // Dispatch input and change events
    r.dispatchEvent(new Event("input", {bubbles: true}));
    r.dispatchEvent(new Event("change", {bubbles: true}));
  }

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

  /**
   * On left mouse clicks, check if the element is contained in a valid hyperlink and open it in a new tab.
   * @param {MouseEvent} event
   * @private
   */
  _onClickHyperlink(event) {
    const a = event.target.closest("a[href]");
    if ( !a || (a.href === "javascript:void(0)") || a.closest(".editor-content.ProseMirror") ) return;
    event.preventDefault();
    window.open(a.href, "_blank");
  }

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

  /**
   * Prevent starting a drag and drop workflow on elements within the document unless the element has the draggable
   * attribute explicitly defined or overrides the dragstart handler.
   * @param {DragEvent} event   The initiating drag start event
   * @private
   */
  _onPreventDragstart(event) {
    const target = event.target;
    const inProseMirror = (target.nodeType === Node.TEXT_NODE) && target.parentElement.closest(".ProseMirror");
    if ( (target.getAttribute?.("draggable") === "true") || inProseMirror ) return;
    event.preventDefault();
    return false;
  }

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

  /**
   * Disallow dragging of external content onto anything but a file input element
   * @param {DragEvent} event   The requested drag event
   * @private
   */
  _onPreventDragover(event) {
    const target = event.target;
    if ( (target.tagName !== "INPUT") || (target.type !== "file") ) event.preventDefault();
  }

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

  /**
   * Disallow dropping of external content onto anything but a file input element
   * @param {DragEvent} event   The requested drag event
   * @private
   */
  _onPreventDrop(event) {
    const target = event.target;
    if ( (target.tagName !== "INPUT") || (target.type !== "file") ) event.preventDefault();
  }

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

  /**
   * On a left-click event, remove any currently displayed inline roll tooltip
   * @param {PointerEvent} event    The mousedown pointer event
   * @private
   */
  _onPointerDown(event) {
    if ([3, 4, 5].includes(event.button)) event.preventDefault();
    const inlineRoll = document.querySelector(".inline-roll.expanded");
    if ( inlineRoll && !event.target.closest(".inline-roll") ) {
      return Roll.defaultImplementation.collapseInlineResult(inlineRoll);
    }
  }

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

  /**
   * Fallback handling for mouse-up events which aren't handled further upstream.
   * @param {PointerEvent} event    The mouseup pointer event
   * @private
   */
  _onPointerUp(event) {
    const cmm = canvas.currentMouseManager;
    if ( !cmm || event.defaultPrevented ) return;
    cmm.cancel(event);
  }

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

  /**
   * Handle resizing of the game window by adjusting the canvas and repositioning active interface applications.
   * @param {Event} event     The window resize event which has occurred
   * @private
   */
  _onWindowResize(event) {
    for ( const appV1 of Object.values(ui.windows) ) {
      appV1.setPosition({top: appV1.position.top, left: appV1.position.left});
    }
    for ( const appV2 of foundry.applications.instances.values() ) appV2.setPosition();
    ui.webrtc?.setPosition({height: "auto"});
    if (canvas && canvas.ready) return canvas._onResize(event);
  }

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

  /**
   * Handle window unload operations to clean up any data which may be pending a final save
   * @param {Event} event     The window unload event which is about to occur
   * @private
   */
  _onWindowBeforeUnload(event) {
    if ( canvas.ready ) {
      canvas.fog.commit();
      // Save the fog immediately rather than waiting for the 3s debounced save as part of commitFog.
      return canvas.fog.save();
    }
  }

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

  /**
   * Handle cases where the browser window loses focus to reset detection of currently pressed keys
   * @param {Event} event   The originating window.blur event
   * @private
   */
  _onWindowBlur(event) {
    game.keyboard?.releaseKeys();
  }

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

  _onWindowPopState(event) {
    if ( game._goingBack ) return;
    history.pushState(null, null, location.href);
    if ( confirm(game.i18n.localize("APP.NavigateBackConfirm")) ) {
      game._goingBack = true;
      history.back();
      history.back();
    }
  }

  /* -------------------------------------------- */
  /*  View Handlers                               */
  /* -------------------------------------------- */

  /**
   * Initialize elements required for the current view
   * @private
   */
  async _initializeView() {
    switch (this.view) {
      case "game":
        return this._initializeGameView();
      case "stream":
        return this._initializeStreamView();
      default:
        throw new Error(`Unknown view URL ${this.view} provided`);
    }
  }

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

  /**
   * Initialization steps for the primary Game view
   * @private
   */
  async _initializeGameView() {

    // Require a valid user cookie and EULA acceptance
    if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license");
    if (!this.userId) {
      console.error("Invalid user session provided - returning to login screen.");
      this.logOut();
    }

    // Set up the game
    await this.setupGame();

    // Set a timeout of 10 minutes before kicking the user off
    if ( this.data.demoMode && !this.user.isGM ) {
      setTimeout(() => {
        console.log(`${vtt} | Ending demo session after 10 minutes. Thanks for testing!`);
        this.logOut();
      }, 1000 * 60 * 10);
    }

    // Context menu listeners
    ContextMenu.eventListeners();

    // ProseMirror menu listeners
    ProseMirror.ProseMirrorMenu.eventListeners();
  }

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

  /**
   * Initialization steps for the Stream helper view
   * @private
   */
  async _initializeStreamView() {
    if ( !globalThis.SIGNED_EULA ) window.location.href = foundry.utils.getRoute("license");
    this.initializeDocuments();
    ui.chat = new ChatLog({stream: true});
    ui.chat.render(true);
    CONFIG.DatabaseBackend.activateSocketListeners(this.socket);
  }

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

  get template() {
    foundry.utils.logCompatibilityWarning("Game#template is deprecated and will be removed in Version 14. "
      + "Use cases for Game#template should be refactored to instead use System#documentTypes or Game#model",
    {since: 12, until: 14, once: true});
    return this.#template;
  }

  #template;
}

/**
 * A specialized subclass of the ClientDocumentMixin which is used for document types that are intended to be
 * represented upon the game Canvas.
 * @category - Mixins
 * @param {typeof abstract.Document} Base     The base document class mixed with client and canvas features
 * @returns {typeof CanvasDocument}           The mixed CanvasDocument class definition
 */
function CanvasDocumentMixin(Base) {
  return class CanvasDocument extends ClientDocumentMixin(Base) {

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

    /**
     * A lazily constructed PlaceableObject instance which can represent this Document on the game canvas.
     * @type {PlaceableObject|null}
     */
    get object() {
      if ( this._object || this._destroyed ) return this._object;
      if ( !this.parent?.isView || !this.layer ) return null;
      return this._object = this.layer.createObject(this);
    }

    /**
     * @type {PlaceableObject|null}
     * @private
     */
    _object = this._object ?? null;

    /**
     * Has this object been deliberately destroyed as part of the deletion workflow?
     * @type {boolean}
     * @private
     */
    _destroyed = false;

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

    /**
     * A reference to the CanvasLayer which contains Document objects of this type.
     * @type {PlaceablesLayer}
     */
    get layer() {
      return canvas.getLayerByEmbeddedName(this.documentName);
    }

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

    /**
     * An indicator for whether this document is currently rendered on the game canvas.
     * @type {boolean}
     */
    get rendered() {
      return this._object && !this._object.destroyed;
    }

    /* -------------------------------------------- */
    /*  Event Handlers                              */
    /* -------------------------------------------- */

    /** @inheritdoc */
    async _preCreate(data, options, user) {
      const allowed = await super._preCreate(data, options, user);
      if ( allowed === false ) return false;
      if ( !this.schema.has("sort") || ("sort" in data) ) return;
      let sort = 0;
      for ( const document of this.collection ) sort = Math.max(sort, document.sort + 1);
      this.updateSource({sort});
    }

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

    /** @inheritDoc */
    _onCreate(data, options, userId) {
      super._onCreate(data, options, userId);
      const object = this.object;
      if ( !object ) return;
      this.layer.objects.addChild(object);
      object.draw().then(() => {
        object?._onCreate(data, options, userId);
      });
    }

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

    /** @inheritDoc */
    _onUpdate(changed, options, userId) {
      super._onUpdate(changed, options, userId);
      this._object?._onUpdate(changed, options, userId);
    }

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

    /** @inheritDoc */
    _onDelete(options, userId) {
      super._onDelete(options, userId);
      this._object?._onDelete(options, userId);
    }
  };
}

/**
 * A mixin which extends each Document definition with specialized client-side behaviors.
 * This mixin defines the client-side interface for database operations and common document behaviors.
 * @param {typeof abstract.Document} Base     The base Document class to be mixed
 * @returns {typeof ClientDocument}           The mixed client-side document class definition
 * @category - Mixins
 * @mixin
 */
function ClientDocumentMixin(Base) {
  /**
   * The ClientDocument extends the base Document class by adding client-specific behaviors to all Document types.
   * @extends {abstract.Document}
   */
  return class ClientDocument extends Base {
    constructor(data, context) {
      super(data, context);

      /**
       * A collection of Application instances which should be re-rendered whenever this document is updated.
       * The keys of this object are the application ids and the values are Application instances. Each
       * Application in this object will have its render method called by {@link Document#render}.
       * @type {Record<string,Application|ApplicationV2>}
       * @see {@link Document#render}
       * @memberof ClientDocumentMixin#
       */
      Object.defineProperty(this, "apps", {
        value: {},
        writable: false,
        enumerable: false
      });

      /**
       * A cached reference to the FormApplication instance used to configure this Document.
       * @type {FormApplication|null}
       * @private
       */
      Object.defineProperty(this, "_sheet", {value: null, writable: true, enumerable: false});
    }

    /** @inheritdoc */
    static name = "ClientDocumentMixin";

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

    /**
     * @inheritDoc
     * @this {ClientDocument}
     */
    _initialize(options={}) {
      super._initialize(options);
      if ( !game._documentsReady ) return;
      return this._safePrepareData();
    }

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

    /**
     * Return a reference to the parent Collection instance which contains this Document.
     * @memberof ClientDocumentMixin#
     * @this {ClientDocument}
     * @type {Collection}
     */
    get collection() {
      if ( this.isEmbedded ) return this.parent[this.parentCollection];
      else return CONFIG[this.documentName].collection.instance;
    }

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

    /**
     * A reference to the Compendium Collection which contains this Document, if any, otherwise undefined.
     * @memberof ClientDocumentMixin#
     * @this {ClientDocument}
     * @type {CompendiumCollection}
     */
    get compendium() {
      return game.packs.get(this.pack);
    }

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

    /**
     * A boolean indicator for whether the current game User has ownership rights for this Document.
     * Different Document types may have more specialized rules for what constitutes ownership.
     * @type {boolean}
     * @memberof ClientDocumentMixin#
     */
    get isOwner() {
      return this.testUserPermission(game.user, "OWNER");
    }

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

    /**
     * Test whether this Document is owned by any non-Gamemaster User.
     * @type {boolean}
     * @memberof ClientDocumentMixin#
     */
    get hasPlayerOwner() {
      return game.users.some(u => !u.isGM && this.testUserPermission(u, "OWNER"));
    }

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

    /**
     * A boolean indicator for whether the current game User has exactly LIMITED visibility (and no greater).
     * @type {boolean}
     * @memberof ClientDocumentMixin#
     */
    get limited() {
      return this.testUserPermission(game.user, "LIMITED", {exact: true});
    }

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

    /**
     * Return a string which creates a dynamic link to this Document instance.
     * @returns {string}
     * @memberof ClientDocumentMixin#
     */
    get link() {
      return `@UUID[${this.uuid}]{${this.name}}`;
    }

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

    /**
     * Return the permission level that the current game User has over this Document.
     * See the CONST.DOCUMENT_OWNERSHIP_LEVELS object for an enumeration of these levels.
     * @type {number}
     * @memberof ClientDocumentMixin#
     *
     * @example Get the permission level the current user has for a document
     * ```js
     * game.user.id; // "dkasjkkj23kjf"
     * actor.data.permission; // {default: 1, "dkasjkkj23kjf": 2};
     * actor.permission; // 2
     * ```
     */
    get permission() {
      if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
      if ( this.isEmbedded ) return this.parent.permission;
      return this.getUserLevel(game.user);
    }

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

    /**
     * Lazily obtain a FormApplication instance used to configure this Document, or null if no sheet is available.
     * @type {Application|ApplicationV2|null}
     * @memberof ClientDocumentMixin#
     */
    get sheet() {
      if ( !this._sheet ) {
        const cls = this._getSheetClass();

        // Application V1 Document Sheets
        if ( foundry.utils.isSubclass(cls, Application) ) {
          this._sheet = new cls(this, {editable: this.isOwner});
        }

        // Application V2 Document Sheets
        else if ( foundry.utils.isSubclass(cls, foundry.applications.api.DocumentSheetV2) ) {
          this._sheet = new cls({document: this});
        }

        // No valid sheet class
        else this._sheet = null;
      }
      return this._sheet;
    }

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

    /**
     * A boolean indicator for whether the current game User has at least limited visibility for this Document.
     * Different Document types may have more specialized rules for what determines visibility.
     * @type {boolean}
     * @memberof ClientDocumentMixin#
     */
    get visible() {
      if ( this.isEmbedded ) return this.parent.visible;
      return this.testUserPermission(game.user, "LIMITED");
    }

    /* -------------------------------------------- */
    /*  Methods                                     */

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

    /**
     * Obtain the FormApplication class constructor which should be used to configure this Document.
     * @returns {Function|null}
     * @private
     */
    _getSheetClass() {
      const cfg = CONFIG[this.documentName];
      const type = this.type ?? CONST.BASE_DOCUMENT_TYPE;
      const sheets = cfg.sheetClasses[type] || {};

      // Sheet selection overridden at the instance level
      const override = this.getFlag("core", "sheetClass") ?? null;
      if ( (override !== null) && (override in sheets) ) return sheets[override].cls;

      // Default sheet selection for the type
      const classes = Object.values(sheets);
      if ( !classes.length ) return BaseSheet;
      return (classes.find(s => s.default) ?? classes.pop()).cls;
    }

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

    /**
     * Safely prepare data for a Document, catching any errors.
     * @internal
     */
    _safePrepareData() {
      try {
        this.prepareData();
      } catch(err) {
        Hooks.onError("ClientDocumentMixin#_initialize", err, {
          msg: `Failed data preparation for ${this.uuid}`,
          log: "error",
          uuid: this.uuid
        });
      }
    }

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

    /**
     * Prepare data for the Document. This method is called automatically by the DataModel#_initialize workflow.
     * This method provides an opportunity for Document classes to define special data preparation logic.
     * The work done by this method should be idempotent. There are situations in which prepareData may be called more
     * than once.
     * @memberof ClientDocumentMixin#
     */
    prepareData() {
      const isTypeData = this.system instanceof foundry.abstract.TypeDataModel;
      if ( isTypeData ) this.system.prepareBaseData();
      this.prepareBaseData();
      this.prepareEmbeddedDocuments();
      if ( isTypeData ) this.system.prepareDerivedData();
      this.prepareDerivedData();
    }

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

    /**
     * Prepare data related to this Document itself, before any embedded Documents or derived data is computed.
     * @memberof ClientDocumentMixin#
     */
    prepareBaseData() {
    }

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

    /**
     * Prepare all embedded Document instances which exist within this primary Document.
     * @memberof ClientDocumentMixin#
     */
    prepareEmbeddedDocuments() {
      for ( const collectionName of Object.keys(this.constructor.hierarchy || {}) ) {
        for ( let e of this.getEmbeddedCollection(collectionName) ) {
          e._safePrepareData();
        }
      }
    }

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

    /**
     * Apply transformations or derivations to the values of the source data object.
     * Compute data fields whose values are not stored to the database.
     * @memberof ClientDocumentMixin#
     */
    prepareDerivedData() {
    }

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

    /**
     * Render all Application instances which are connected to this document by calling their respective
     * @see Application#render
     * @param {boolean} [force=false]     Force rendering
     * @param {object} [context={}]       Optional context
     * @memberof ClientDocumentMixin#
     */
    render(force=false, context={}) {
      for ( let app of Object.values(this.apps) ) {
        app.render(force, foundry.utils.deepClone(context));
      }
    }

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

    /**
     * Determine the sort order for this Document by positioning it relative a target sibling.
     * See SortingHelper.performIntegerSort for more details
     * @param {object} [options]          Sorting options provided to SortingHelper.performIntegerSort
     * @param {object} [updateData]       Additional data changes which are applied to each sorted document
     * @param {object} [sortOptions]      Options which are passed to the SortingHelpers.performIntegerSort method
     * @returns {Promise<Document>}       The Document after it has been re-sorted
     * @memberof ClientDocumentMixin#
     */
    async sortRelative({updateData={}, ...sortOptions}={}) {
      const sorting = SortingHelpers.performIntegerSort(this, sortOptions);
      const updates = [];
      for ( let s of sorting ) {
        const doc = s.target;
        const update = foundry.utils.mergeObject(updateData, s.update, {inplace: false});
        update._id = doc._id;
        if ( doc.sheet && doc.sheet.rendered ) await doc.sheet.submit({updateData: update});
        else updates.push(update);
      }
      if ( updates.length ) await this.constructor.updateDocuments(updates, {parent: this.parent, pack: this.pack});
      return this;
    }

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

    /**
     * Construct a UUID relative to another document.
     * @param {ClientDocument} relative  The document to compare against.
     */
    getRelativeUUID(relative) {
      // The Documents are in two different compendia.
      if ( this.compendium && (this.compendium !== relative.compendium) ) return this.uuid;

      // This Document is a sibling of the relative Document.
      if ( this.isEmbedded && (this.collection === relative.collection) ) return `.${this.id}`;

      // This Document may be a descendant of the relative Document, so walk up the hierarchy to check.
      const parts = [this.documentName, this.id];
      let parent = this.parent;
      while ( parent ) {
        if ( parent === relative ) break;
        parts.unshift(parent.documentName, parent.id);
        parent = parent.parent;
      }

      // The relative Document was a parent or grandparent of this one.
      if ( parent === relative ) return `.${parts.join(".")}`;

      // The relative Document was unrelated to this one.
      return this.uuid;
    }

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

    /**
     * Create a content link for this document.
     * @param {object} eventData                     The parsed object of data provided by the drop transfer event.
     * @param {object} [options]                     Additional options to configure link generation.
     * @param {ClientDocument} [options.relativeTo]  A document to generate a link relative to.
     * @param {string} [options.label]               A custom label to use instead of the document's name.
     * @returns {string}
     * @internal
     */
    _createDocumentLink(eventData, {relativeTo, label}={}) {
      if ( !relativeTo && !label ) return this.link;
      label ??= this.name;
      if ( relativeTo ) return `@UUID[${this.getRelativeUUID(relativeTo)}]{${label}}`;
      return `@UUID[${this.uuid}]{${label}}`;
    }

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

    /**
     * Handle clicking on a content link for this document.
     * @param {MouseEvent} event    The triggering click event.
     * @returns {any}
     * @protected
     */
    _onClickDocumentLink(event) {
      return this.sheet.render(true);
    }

    /* -------------------------------------------- */
    /*  Event Handlers                              */
    /* -------------------------------------------- */

    /** @inheritDoc */
    async _preCreate(data, options, user) {
      const allowed = await super._preCreate(data, options, user);
      if ( allowed === false ) return false;

      // Forward to type data model
      if ( this.system instanceof foundry.abstract.TypeDataModel ) {
        return this.system._preCreate(data, options, user);
      }
    }

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

    /** @inheritDoc */
    _onCreate(data, options, userId) {
      super._onCreate(data, options, userId);

      // Render the sheet for this application
      if ( options.renderSheet && (userId === game.user.id) && this.sheet ) {
        const options = {
          renderContext: `create${this.documentName}`,
          renderData: data
        };
        /** @deprecated since v12 */
        Object.defineProperties(options, {
          action: { get() {
            foundry.utils.logCompatibilityWarning("The render options 'action' property is deprecated. "
              + "Please use 'renderContext' instead.", { since: 12, until: 14 });
            return "create";
          } },
          data: { get() {
            foundry.utils.logCompatibilityWarning("The render options 'data' property is deprecated. "
              + "Please use 'renderData' instead.", { since: 12, until: 14 });
            return data;
          } }
        });
        this.sheet.render(true, options);
      }

      // Update Compendium and global indices
      if ( this.pack && !this.isEmbedded ) {
        if ( this instanceof Folder ) this.compendium.folders.set(this.id, this);
        else this.compendium.indexDocument(this);
      }
      if ( this.constructor.metadata.indexed ) game.documentIndex.addDocument(this);

      // Update support metadata
      game.issues._countDocumentSubType(this.constructor, this._source);

      // Forward to type data model
      if ( this.system instanceof foundry.abstract.TypeDataModel ) {
        this.system._onCreate(data, options, userId);
      }
    }

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

    /** @inheritDoc */
    async _preUpdate(changes, options, user) {
      const allowed = await super._preUpdate(changes, options, user);
      if ( allowed === false ) return false;

      // Forward to type data model
      if ( this.system instanceof foundry.abstract.TypeDataModel ) {
        return this.system._preUpdate(changes, options, user);
      }
    }

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

    /** @inheritDoc */
    _onUpdate(changed, options, userId) {
      super._onUpdate(changed, options, userId);

      // Clear cached sheet if a new sheet is chosen, or the Document's sub-type changes.
      const sheetChanged = ("type" in changed) || ("sheetClass" in (changed.flags?.core || {}));
      if ( !options.preview && sheetChanged ) this._onSheetChange();

      // Otherwise re-render associated applications.
      else if ( options.render !== false ) {
        const options = {
          renderContext: `update${this.documentName}`,
          renderData: changed
        };
        /** @deprecated since v12 */
        Object.defineProperties(options, {
          action: {
            get() {
              foundry.utils.logCompatibilityWarning("The render options 'action' property is deprecated. "
                + "Please use 'renderContext' instead.", { since: 12, until: 14 });
              return "update";
            }
          },
          data: {
            get() {
              foundry.utils.logCompatibilityWarning("The render options 'data' property is deprecated. "
                + "Please use 'renderData' instead.", { since: 12, until: 14 });
              return changed;
            }
          }
        });
        this.render(false, options);
      }

      // Update Compendium and global indices
      if ( this.pack && !this.isEmbedded ) {
        if ( this instanceof Folder ) this.compendium.folders.set(this.id, this);
        else this.compendium.indexDocument(this);
      }
      if ( "name" in changed ) game.documentIndex.replaceDocument(this);

      // Forward to type data model
      if ( this.system instanceof foundry.abstract.TypeDataModel ) {
        this.system._onUpdate(changed, options, userId);
      }
    }

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

    /** @inheritDoc */
    async _preDelete(options, user) {
      const allowed = await super._preDelete(options, user);
      if ( allowed === false ) return false;

      // Forward to type data model
      if ( this.system instanceof foundry.abstract.TypeDataModel ) {
        return this.system._preDelete(options, user);
      }
    }

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

    /** @inheritDoc */
    _onDelete(options, userId) {
      super._onDelete(options, userId);

      // Close open Applications for this Document
      const renderOptions = {
        submit: false,
        renderContext: `delete${this.documentName}`,
        renderData: this
      };
      /** @deprecated since v12 */
      Object.defineProperties(renderOptions, {
        action: {
          get() {
            foundry.utils.logCompatibilityWarning("The render options 'action' property is deprecated. "
              + "Please use 'renderContext' instead.", {since: 12, until: 14});
            return "delete";
          }
        },
        data: {
          get() {
            foundry.utils.logCompatibilityWarning("The render options 'data' property is deprecated. "
              + "Please use 'renderData' instead.", {since: 12, until: 14});
            return this;
          }
        }
      });
      Object.values(this.apps).forEach(a => a.close(renderOptions));

      // Update Compendium and global indices
      if ( this.pack && !this.isEmbedded ) {
        if ( this instanceof Folder ) this.compendium.folders.delete(this.id);
        else this.compendium.index.delete(this.id);
      }
      game.documentIndex.removeDocument(this);

      // Update support metadata
      game.issues._countDocumentSubType(this.constructor, this._source, {decrement: true});

      // Forward to type data model
      if ( this.system instanceof foundry.abstract.TypeDataModel ) {
        this.system._onDelete(options, userId);
      }
    }

    /* -------------------------------------------- */
    /*  Descendant Document Events                  */
    /* -------------------------------------------- */

    /**
     * Orchestrate dispatching descendant document events to parent documents when embedded children are modified.
     * @param {string} event                The event name, preCreate, onCreate, etc...
     * @param {string} collection           The collection name being modified within this parent document
     * @param {Array<*>} args               Arguments passed to each dispatched function
     * @param {ClientDocument} [_parent]    The document with directly modified embedded documents.
     *                                      Either this document or a descendant of this one.
     * @internal
     */
    _dispatchDescendantDocumentEvents(event, collection, args, _parent) {
      _parent ||= this;

      // Dispatch the event to this Document
      const fn = this[`_${event}DescendantDocuments`];
      if ( !(fn instanceof Function) ) throw new Error(`Invalid descendant document event "${event}"`);
      fn.call(this, _parent, collection, ...args);

      // Dispatch the legacy "EmbeddedDocuments" event to the immediate parent only
      if ( _parent === this ) {
        /** @deprecated since v11 */
        const legacyFn = `_${event}EmbeddedDocuments`;
        const isOverridden = foundry.utils.getDefiningClass(this, legacyFn)?.name !== "ClientDocumentMixin";
        if ( isOverridden && (this[legacyFn] instanceof Function) ) {
          const documentName = this.constructor.hierarchy[collection].model.documentName;
          const warning = `The ${this.documentName} class defines the _${event}EmbeddedDocuments method which is `
            + `deprecated in favor of a new _${event}DescendantDocuments method.`;
          foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
          this[legacyFn](documentName, ...args);
        }
      }

      // Bubble the event to the parent Document
      /** @type ClientDocument */
      const parent = this.parent;
      if ( !parent ) return;
      parent._dispatchDescendantDocumentEvents(event, collection, args, _parent);
    }

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

    /**
     * Actions taken after descendant documents have been created, but before changes are applied to the client data.
     * @param {Document} parent         The direct parent of the created Documents, may be this Document or a child
     * @param {string} collection       The collection within which documents are being created
     * @param {object[]} data           The source data for new documents that are being created
     * @param {object} options          Options which modified the creation operation
     * @param {string} userId           The ID of the User who triggered the operation
     * @protected
     */
    _preCreateDescendantDocuments(parent, collection, data, options, userId) {}

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

    /**
     * Actions taken after descendant documents have been created and changes have been applied to client data.
     * @param {Document} parent         The direct parent of the created Documents, may be this Document or a child
     * @param {string} collection       The collection within which documents were created
     * @param {Document[]} documents    The array of created Documents
     * @param {object[]} data           The source data for new documents that were created
     * @param {object} options          Options which modified the creation operation
     * @param {string} userId           The ID of the User who triggered the operation
     * @protected
     */
    _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
      if ( options.render === false ) return;
      this.render(false, {renderContext: `create${collection}`, renderData: data});
    }

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

    /**
     * Actions taken after descendant documents have been updated, but before changes are applied to the client data.
     * @param {Document} parent         The direct parent of the updated Documents, may be this Document or a child
     * @param {string} collection       The collection within which documents are being updated
     * @param {object[]} changes        The array of differential Document updates to be applied
     * @param {object} options          Options which modified the update operation
     * @param {string} userId           The ID of the User who triggered the operation
     * @protected
     */
    _preUpdateDescendantDocuments(parent, collection, changes, options, userId) {}

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

    /**
     * Actions taken after descendant documents have been updated and changes have been applied to client data.
     * @param {Document} parent         The direct parent of the updated Documents, may be this Document or a child
     * @param {string} collection       The collection within which documents were updated
     * @param {Document[]} documents    The array of updated Documents
     * @param {object[]} changes        The array of differential Document updates which were applied
     * @param {object} options          Options which modified the update operation
     * @param {string} userId           The ID of the User who triggered the operation
     * @protected
     */
    _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
      if ( options.render === false ) return;
      this.render(false, {renderContext: `update${collection}`, renderData: changes});
    }

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

    /**
     * Actions taken after descendant documents have been deleted, but before deletions are applied to the client data.
     * @param {Document} parent         The direct parent of the deleted Documents, may be this Document or a child
     * @param {string} collection       The collection within which documents were deleted
     * @param {string[]} ids            The array of document IDs which were deleted
     * @param {object} options          Options which modified the deletion operation
     * @param {string} userId           The ID of the User who triggered the operation
     * @protected
     */
    _preDeleteDescendantDocuments(parent, collection, ids, options, userId) {}

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

    /**
     * Actions taken after descendant documents have been deleted and those deletions have been applied to client data.
     * @param {Document} parent         The direct parent of the deleted Documents, may be this Document or a child
     * @param {string} collection       The collection within which documents were deleted
     * @param {Document[]} documents    The array of Documents which were deleted
     * @param {string[]} ids            The array of document IDs which were deleted
     * @param {object} options          Options which modified the deletion operation
     * @param {string} userId           The ID of the User who triggered the operation
     * @protected
     */
    _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
      if ( options.render === false ) return;
      this.render(false, {renderContext: `delete${collection}`, renderData: ids});
    }

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

    /**
     * Whenever the Document's sheet changes, close any existing applications for this Document, and re-render the new
     * sheet if one was already open.
     * @param {object} [options]
     * @param {boolean} [options.sheetOpen]  Whether the sheet was originally open and needs to be re-opened.
     * @internal
     */
    async _onSheetChange({ sheetOpen }={}) {
      sheetOpen ??= this.sheet.rendered;
      await Promise.all(Object.values(this.apps).map(app => app.close()));
      this._sheet = null;
      if ( sheetOpen ) this.sheet.render(true);

      // Re-draw the parent sheet in case of a dependency on the child sheet.
      this.parent?.sheet?.render(false);
    }

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

    /**
     * Gets the default new name for a Document
     * @param {object} context                    The context for which to create the Document name.
     * @param {string} [context.type]             The sub-type of the document
     * @param {Document|null} [context.parent]    A parent document within which the created Document should belong
     * @param {string|null} [context.pack]        A compendium pack within which the Document should be created
     * @returns {string}
     */
    static defaultName({type, parent, pack}={}) {
      const documentName = this.metadata.name;
      let collection;
      if ( parent ) collection = parent.getEmbeddedCollection(documentName);
      else if ( pack ) collection = game.packs.get(pack);
      else collection = game.collections.get(documentName);
      const takenNames = new Set();
      for ( const document of collection ) takenNames.add(document.name);
      let baseNameKey = this.metadata.label;
      if ( type && this.hasTypeData ) {
        const typeNameKey = CONFIG[documentName].typeLabels?.[type];
        if ( typeNameKey && game.i18n.has(typeNameKey) ) baseNameKey = typeNameKey;
      }
      const baseName = game.i18n.localize(baseNameKey);
      let name = baseName;
      let index = 1;
      while ( takenNames.has(name) ) name = `${baseName} (${++index})`;
      return name;
    }

    /* -------------------------------------------- */
    /*  Importing and Exporting                     */
    /* -------------------------------------------- */

    /**
     * Present a Dialog form to create a new Document of this type.
     * Choose a name and a type from a select menu of types.
     * @param {object} data              Initial data with which to populate the creation form
     * @param {object} [context={}]      Additional context options or dialog positioning options
     * @param {Document|null} [context.parent]   A parent document within which the created Document should belong
     * @param {string|null} [context.pack]       A compendium pack within which the Document should be created
     * @param {string[]} [context.types]         A restriction the selectable sub-types of the Dialog.
     * @returns {Promise<Document|null>} A Promise which resolves to the created Document, or null if the dialog was
     *                                   closed.
     * @memberof ClientDocumentMixin
     */
    static async createDialog(data={}, {parent=null, pack=null, types, ...options}={}) {
      const cls = this.implementation;

      // Identify allowed types
      let documentTypes = [];
      let defaultType = CONFIG[this.documentName]?.defaultType;
      let defaultTypeAllowed = false;
      let hasTypes = false;
      if ( this.TYPES.length > 1 ) {
        if ( types?.length === 0 ) throw new Error("The array of sub-types to restrict to must not be empty");

        // Register supported types
        for ( const type of this.TYPES ) {
          if ( type === CONST.BASE_DOCUMENT_TYPE ) continue;
          if ( types && !types.includes(type) ) continue;
          let label = CONFIG[this.documentName]?.typeLabels?.[type];
          label = label && game.i18n.has(label) ? game.i18n.localize(label) : type;
          documentTypes.push({value: type, label});
          if ( type === defaultType ) defaultTypeAllowed = true;
        }
        if ( !documentTypes.length ) throw new Error("No document types were permitted to be created");

        if ( !defaultTypeAllowed ) defaultType = documentTypes[0].value;
        // Sort alphabetically
        documentTypes.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
        hasTypes = true;
      }

      // Identify destination collection
      let collection;
      if ( !parent ) {
        if ( pack ) collection = game.packs.get(pack);
        else collection = game.collections.get(this.documentName);
      }

      // Collect data
      const folders = collection?._formatFolderSelectOptions() ?? [];
      const label = game.i18n.localize(this.metadata.label);
      const title = game.i18n.format("DOCUMENT.Create", {type: label});
      const type = data.type || defaultType;

      // Render the document creation form
      const html = await renderTemplate("templates/sidebar/document-create.html", {
        folders,
        name: data.name || "",
        defaultName: cls.defaultName({type, parent, pack}),
        folder: data.folder,
        hasFolders: folders.length >= 1,
        hasTypes,
        type,
        types: documentTypes
      });

      // Render the confirmation dialog window
      return Dialog.prompt({
        title,
        content: html,
        label: title,
        render: html => {
          if ( !hasTypes ) return;
          html[0].querySelector('[name="type"]').addEventListener("change", e => {
            const nameInput = html[0].querySelector('[name="name"]');
            nameInput.placeholder = cls.defaultName({type: e.target.value, parent, pack});
          });
        },
        callback: html => {
          const form = html[0].querySelector("form");
          const fd = new FormDataExtended(form);
          foundry.utils.mergeObject(data, fd.object, {inplace: true});
          if ( !data.folder ) delete data.folder;
          if ( !data.name?.trim() ) data.name = cls.defaultName({type: data.type, parent, pack});
          return cls.create(data, {parent, pack, renderSheet: true});
        },
        rejectClose: false,
        options
      });
    }

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

    /**
     * Present a Dialog form to confirm deletion of this Document.
     * @param {object} [options]    Positioning and sizing options for the resulting dialog
     * @returns {Promise<Document>} A Promise which resolves to the deleted Document
     */
    async deleteDialog(options={}) {
      const type = game.i18n.localize(this.constructor.metadata.label);
      return Dialog.confirm({
        title: `${game.i18n.format("DOCUMENT.Delete", {type})}: ${this.name}`,
        content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.format("SIDEBAR.DeleteWarning", {type})}</p>`,
        yes: () => this.delete(),
        options: options
      });
    }

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

    /**
     * Export document data to a JSON file which can be saved by the client and later imported into a different session.
     * Only world Documents may be exported.
     * @param {object} [options]      Additional options passed to the {@link ClientDocumentMixin#toCompendium} method
     * @memberof ClientDocumentMixin#
     */
    exportToJSON(options) {
      if ( !CONST.WORLD_DOCUMENT_TYPES.includes(this.documentName) ) {
        throw new Error("Only world Documents may be exported");
      }
      const data = this.toCompendium(null, options);
      data.flags.exportSource = {
        world: game.world.id,
        system: game.system.id,
        coreVersion: game.version,
        systemVersion: game.system.version
      };
      const filename = ["fvtt", this.documentName, this.name?.slugify(), this.id].filterJoin("-");
      saveDataToFile(JSON.stringify(data, null, 2), "text/json", `${filename}.json`);
    }

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

    /**
     * Serialize salient information about this Document when dragging it.
     * @returns {object}  An object of drag data.
     */
    toDragData() {
      const dragData = {type: this.documentName};
      if ( this.id ) dragData.uuid = this.uuid;
      else dragData.data = this.toObject();
      return dragData;
    }

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

    /**
     * A helper function to handle obtaining the relevant Document from dropped data provided via a DataTransfer event.
     * The dropped data could have:
     * 1. A data object explicitly provided
     * 2. A UUID
     * @memberof ClientDocumentMixin
     *
     * @param {object} data           The data object extracted from a DataTransfer event
     * @param {object} options        Additional options which affect drop data behavior
     * @returns {Promise<Document>}   The resolved Document
     * @throws If a Document could not be retrieved from the provided data.
     */
    static async fromDropData(data, options={}) {
      let document = null;

      // Case 1 - Data explicitly provided
      if ( data.data ) document = new this(data.data);

      // Case 2 - UUID provided
      else if ( data.uuid ) document = await fromUuid(data.uuid);

      // Ensure that we retrieved a valid document
      if ( !document ) {
        throw new Error("Failed to resolve Document from provided DragData. Either data or a UUID must be provided.");
      }
      if ( document.documentName !== this.documentName ) {
        throw new Error(`Invalid Document type '${document.type}' provided to ${this.name}.fromDropData.`);
      }

      // Flag the source UUID
      if ( document.id && !document._stats?.compendiumSource ) {
        document.updateSource({"_stats.compendiumSource": document.uuid});
      }
      return document;
    }

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

    /**
     * Create the Document from the given source with migration applied to it.
     * Only primary Documents may be imported.
     *
     * This function must be used to create a document from data that predates the current core version.
     * It must be given nonpartial data matching the schema it had in the core version it is coming from.
     * It applies legacy migrations to the source data before calling {@link Document.fromSource}.
     * If this function is not used to import old data, necessary migrations may not applied to the data
     * resulting in an incorrectly imported document.
     *
     * The core version is recorded in the `_stats` field, which all primary documents have. If the given source data
     * doesn't contain a `_stats` field, the data is assumed to be pre-V10, when the `_stats` field didn't exist yet.
     * The `_stats` field must not be stripped from the data before it is exported!
     * @param {object} source                  The document data that is imported.
     * @param {DocumentConstructionContext & DataValidationOptions} [context]
     *   The model construction context passed to {@link Document.fromSource}.
     * @param {boolean} [context.strict=true]  Strict validation is enabled by default.
     * @returns {Promise<Document>}
     */
    static async fromImport(source, context) {
      if ( !CONST.PRIMARY_DOCUMENT_TYPES.includes(this.documentName) ) {
        throw new Error("Only primary Documents may be imported");
      }
      const coreVersion = source._stats?.coreVersion;
      if ( coreVersion && foundry.utils.isNewerVersion(coreVersion, game.version) ) {
        throw new Error("Documents from a core version newer than the running version cannot be imported");
      }
      if ( coreVersion !== game.version ) {
        const response = await new Promise(resolve => {
          game.socket.emit("migrateDocumentData", this.documentName, source, resolve);
        });
        if ( response.error ) throw new Error(response.error);
        source = response.source;
      }
      return this.fromSource(source, {strict: true, ...context});
    }

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

    /**
     * Update this Document using a provided JSON string.
     * Only world Documents may be imported.
     * @this {ClientDocument}
     * @param {string} json                 Raw JSON data to import
     * @returns {Promise<ClientDocument>}   The updated Document instance
     */
    async importFromJSON(json) {
      if ( !CONST.WORLD_DOCUMENT_TYPES.includes(this.documentName) ) {
        throw new Error("Only world Documents may be imported");
      }

      // Create a document from the JSON data
      const parsedJSON = JSON.parse(json);
      const doc = await this.constructor.fromImport(parsedJSON);

      // Treat JSON import using the same workflows that are used when importing from a compendium pack
      const data = this.collection.fromCompendium(doc);

      // Preserve certain fields from the destination document
      const preserve = Object.fromEntries(this.constructor.metadata.preserveOnImport.map(k => {
        return [k, foundry.utils.getProperty(this, k)];
      }));
      preserve.folder = this.folder?.id;
      foundry.utils.mergeObject(data, preserve);

      // Commit the import as an update to this document
      await this.update(data, {diff: false, recursive: false, noHook: true});
      ui.notifications.info(game.i18n.format("DOCUMENT.Imported", {document: this.documentName, name: this.name}));
      return this;
    }

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

    /**
     * Render an import dialog for updating the data related to this Document through an exported JSON file
     * @returns {Promise<void>}
     * @memberof ClientDocumentMixin#
     */
    async importFromJSONDialog() {
      new Dialog({
        title: `Import Data: ${this.name}`,
        content: await renderTemplate("templates/apps/import-data.html", {
          hint1: game.i18n.format("DOCUMENT.ImportDataHint1", {document: this.documentName}),
          hint2: game.i18n.format("DOCUMENT.ImportDataHint2", {name: this.name})
        }),
        buttons: {
          import: {
            icon: '<i class="fas fa-file-import"></i>',
            label: "Import",
            callback: html => {
              const form = html.find("form")[0];
              if ( !form.data.files.length ) return ui.notifications.error("You did not upload a data file!");
              readTextFromFile(form.data.files[0]).then(json => this.importFromJSON(json));
            }
          },
          no: {
            icon: '<i class="fas fa-times"></i>',
            label: "Cancel"
          }
        },
        default: "import"
      }, {
        width: 400
      }).render(true);
    }

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

    /**
     * Transform the Document data to be stored in a Compendium pack.
     * Remove any features of the data which are world-specific.
     * @param {CompendiumCollection} [pack]   A specific pack being exported to
     * @param {object} [options]              Additional options which modify how the document is converted
     * @param {boolean} [options.clearFlags=false]      Clear the flags object
     * @param {boolean} [options.clearSource=true]      Clear any prior source information
     * @param {boolean} [options.clearSort=true]        Clear the currently assigned sort order
     * @param {boolean} [options.clearFolder=false]     Clear the currently assigned folder
     * @param {boolean} [options.clearOwnership=true]   Clear document ownership
     * @param {boolean} [options.clearState=true]       Clear fields which store document state
     * @param {boolean} [options.keepId=false]          Retain the current Document id
     * @returns {object}                      A data object of cleaned data suitable for compendium import
     * @memberof ClientDocumentMixin#
     */
    toCompendium(pack, {clearSort=true, clearFolder=false, clearFlags=false, clearSource=true, clearOwnership=true,
      clearState=true, keepId=false} = {}) {
      const data = this.toObject();
      if ( !keepId ) delete data._id;
      if ( clearSort ) delete data.sort;
      if ( clearFolder ) delete data.folder;
      if ( clearFlags ) delete data.flags;
      if ( clearSource ) {
        delete data._stats?.compendiumSource;
        delete data._stats?.duplicateSource;
      }
      if ( clearOwnership ) delete data.ownership;
      if ( clearState ) delete data.active;
      return data;
    }

    /* -------------------------------------------- */
    /*  Enrichment                                  */
    /* -------------------------------------------- */

    /**
     * Create a content link for this Document.
     * @param {Partial<EnrichmentAnchorOptions>} [options]  Additional options to configure how the link is constructed.
     * @returns {HTMLAnchorElement}
     */
    toAnchor({attrs={}, dataset={}, classes=[], name, icon}={}) {

      // Build dataset
      const documentConfig = CONFIG[this.documentName];
      const documentName = game.i18n.localize(`DOCUMENT.${this.documentName}`);
      let anchorIcon = icon ?? documentConfig.sidebarIcon;
      if ( !classes.includes("content-link") ) classes.unshift("content-link");
      attrs = foundry.utils.mergeObject({ draggable: "true" }, attrs);
      dataset = foundry.utils.mergeObject({
        link: "",
        uuid: this.uuid,
        id: this.id,
        type: this.documentName,
        pack: this.pack,
        tooltip: documentName
      }, dataset);

      // If this is a typed document, add the type to the dataset
      if ( this.type ) {
        const typeLabel = documentConfig.typeLabels[this.type];
        const typeName = game.i18n.has(typeLabel) ? `${game.i18n.localize(typeLabel)}` : "";
        dataset.tooltip = typeName ? game.i18n.format("DOCUMENT.TypePageFormat", {type: typeName, page: documentName})
          : documentName;
        anchorIcon = icon ?? documentConfig.typeIcons?.[this.type] ?? documentConfig.sidebarIcon;
      }

      name ??= this.name;
      return TextEditor.createAnchor({ attrs, dataset, name, classes, icon: anchorIcon });
    }

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

    /**
     * Convert a Document to some HTML display for embedding purposes.
     * @param {DocumentHTMLEmbedConfig} config  Configuration for embedding behavior.
     * @param {EnrichmentOptions} [options]     The original enrichment options for cases where the Document embed
     *                                          content also contains text that must be enriched.
     * @returns {Promise<HTMLElement|null>}     A representation of the Document as HTML content, or null if such a
     *                                          representation could not be generated.
     */
    async toEmbed(config, options={}) {
      const content = await this._buildEmbedHTML(config, options);
      if ( !content ) return null;
      let embed;
      if ( config.inline ) embed = await this._createInlineEmbed(content, config, options);
      else embed = await this._createFigureEmbed(content, config, options);
      if ( embed ) {
        embed.classList.add("content-embed");
        embed.dataset.uuid = this.uuid;
        embed.dataset.contentEmbed = "";
        if ( config.classes ) embed.classList.add(...config.classes.split(" "));
      }
      return embed;
    }

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

    /**
     * A method that can be overridden by subclasses to customize embedded HTML generation.
     * @param {DocumentHTMLEmbedConfig} config  Configuration for embedding behavior.
     * @param {EnrichmentOptions} [options]     The original enrichment options for cases where the Document embed
     *                                          content also contains text that must be enriched.
     * @returns {Promise<HTMLElement|HTMLCollection|null>}  Either a single root element to append, or a collection of
     *                                                      elements that comprise the embedded content.
     * @protected
     */
    async _buildEmbedHTML(config, options={}) {
      return this.system instanceof foundry.abstract.TypeDataModel ? this.system.toEmbed(config, options) : null;
    }

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

    /**
     * A method that can be overridden by subclasses to customize inline embedded HTML generation.
     * @param {HTMLElement|HTMLCollection} content  The embedded content.
     * @param {DocumentHTMLEmbedConfig} config      Configuration for embedding behavior.
     * @param {EnrichmentOptions} [options]         The original enrichment options for cases where the Document embed
     *                                              content also contains text that must be enriched.
     * @returns {Promise<HTMLElement|null>}
     * @protected
     */
    async _createInlineEmbed(content, config, options) {
      const section = document.createElement("section");
      if ( content instanceof HTMLCollection ) section.append(...content);
      else section.append(content);
      return section;
    }

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

    /**
     * A method that can be overridden by subclasses to customize the generation of the embed figure.
     * @param {HTMLElement|HTMLCollection} content  The embedded content.
     * @param {DocumentHTMLEmbedConfig} config      Configuration for embedding behavior.
     * @param {EnrichmentOptions} [options]         The original enrichment options for cases where the Document embed
     *                                              content also contains text that must be enriched.
     * @returns {Promise<HTMLElement|null>}
     * @protected
     */
    async _createFigureEmbed(content, { cite, caption, captionPosition="bottom", label }, options) {
      const figure = document.createElement("figure");
      if ( content instanceof HTMLCollection ) figure.append(...content);
      else figure.append(content);
      if ( cite || caption ) {
        const figcaption = document.createElement("figcaption");
        if ( caption ) figcaption.innerHTML += `<strong class="embed-caption">${label || this.name}</strong>`;
        if ( cite ) figcaption.innerHTML += `<cite>${this.toAnchor().outerHTML}</cite>`;
        figure.insertAdjacentElement(captionPosition === "bottom" ? "beforeend" : "afterbegin", figcaption);
        if ( captionPosition === "top" ) figure.append(figcaption.querySelector(":scope > cite"));
      }
      return figure;
    }

    /* -------------------------------------------- */
    /*  Deprecations                                */
    /* -------------------------------------------- */

    /**
     * The following are stubs to prevent errors where existing classes may be attempting to call them via super.
     */

    /**
     * @deprecated since v11
     * @ignore
     */
    _preCreateEmbeddedDocuments() {}

    /**
     * @deprecated since v11
     * @ignore
     */
    _preUpdateEmbeddedDocuments() {}

    /**
     * @deprecated since v11
     * @ignore
     */
    _preDeleteEmbeddedDocuments() {}

    /**
     * @deprecated since v11
     * @ignore
     */
    _onCreateEmbeddedDocuments() {}

    /**
     * @deprecated since v11
     * @ignore
     */
    _onUpdateEmbeddedDocuments() {}

    /**
     * @deprecated since v11
     * @ignore
     */
    _onDeleteEmbeddedDocuments() {}
  };
}

/**
 * A mixin which adds directory functionality to a DocumentCollection, such as folders, tree structures, and sorting.
 * @param {typeof Collection} BaseCollection      The base collection class to extend
 * @returns {typeof DirectoryCollection}          A Collection mixed with DirectoryCollection functionality
 * @category - Mixins
 * @mixin
 */
function DirectoryCollectionMixin(BaseCollection) {

  /**
   * An extension of the Collection class which adds behaviors specific to tree-based collections of entries and folders.
   * @extends {Collection}
   */
  return class DirectoryCollection extends BaseCollection {

    /**
     * Reference the set of Folders which contain documents in this collection
     * @type {Collection<string, Folder>}
     */
    get folders() {
      throw new Error("You must implement the folders getter for this DirectoryCollection");
    }

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

    /**
     * The built tree structure of the DocumentCollection
     * @type {object}
     */
    get tree() {
      if ( !this.#tree ) this.initializeTree();
      return this.#tree;
    }

    /**
     * The built tree structure of the DocumentCollection. Lazy initialized.
     * @type {object}
     */
    #tree;

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

    /**
     * The current search mode for this collection
     * @type {string}
     */
    get searchMode() {
      const searchModes = game.settings.get("core", "collectionSearchModes");
      return searchModes[this.collection ?? this.name] || CONST.DIRECTORY_SEARCH_MODES.NAME;
    }

    /**
     * Toggle the search mode for this collection between "name" and "full" text search
     */
    toggleSearchMode() {
      const name = this.collection ?? this.name;
      const searchModes = game.settings.get("core", "collectionSearchModes");
      searchModes[name] = searchModes[name] === CONST.DIRECTORY_SEARCH_MODES.FULL
        ? CONST.DIRECTORY_SEARCH_MODES.NAME
        : CONST.DIRECTORY_SEARCH_MODES.FULL;
      game.settings.set("core", "collectionSearchModes", searchModes);
    }

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

    /**
     * The current sort mode used to order the top level entries in this collection
     * @type {string}
     */
    get sortingMode() {
      const sortingModes = game.settings.get("core", "collectionSortingModes");
      return sortingModes[this.collection ?? this.name] || "a";
    }

    /**
     * Toggle the sorting mode for this collection between "a" (Alphabetical) and "m" (Manual by sort property)
     */
    toggleSortingMode() {
      const name = this.collection ?? this.name;
      const sortingModes = game.settings.get("core", "collectionSortingModes");
      const updatedSortingMode = sortingModes[name] === "a" ? "m" : "a";
      sortingModes[name] = updatedSortingMode;
      game.settings.set("core", "collectionSortingModes", sortingModes);
      this.initializeTree();
    }

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

    /**
     * The maximum depth of folder nesting which is allowed in this collection
     * @returns {number}
     */
    get maxFolderDepth() {
      return CONST.FOLDER_MAX_DEPTH;
    }

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

    /**
     * Return a reference to list of entries which are visible to the User in this tree
     * @returns {Array<*>}
     * @private
     */
    _getVisibleTreeContents() {
      return this.contents;
    }

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

    /**
     * Initialize the tree by categorizing folders and entries into a hierarchical tree structure.
     */
    initializeTree() {
      const folders = this.folders.contents;
      const entries = this._getVisibleTreeContents();
      this.#tree = this.#buildTree(folders, entries);
    }

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

    /**
     * Given a list of Folders and a list of Entries, set up the Folder tree
     * @param {Folder[]} folders        The Array of Folder objects to organize
     * @param {Object[]} entries        The Array of Entries objects to organize
     * @returns {object}                A tree structure containing the folders and entries
     */
    #buildTree(folders, entries) {
      const handled = new Set();
      const createNode = (root, folder, depth) => {
        return {root, folder, depth, visible: false, children: [], entries: []};
      };

      // Create the tree structure
      const tree = createNode(true, null, 0);
      const depths = [[tree]];

      // Iterate by folder depth, populating content
      for ( let depth = 1; depth <= this.maxFolderDepth + 1; depth++ ) {
        const allowChildren = depth <= this.maxFolderDepth;
        depths[depth] = [];
        const nodes = depths[depth - 1];
        if ( !nodes.length ) break;
        for ( const node of nodes ) {
          const folder = node.folder;
          if ( !node.root ) { // Ensure we don't encounter any infinite loop
            if ( handled.has(folder.id) ) continue;
            handled.add(folder.id);
          }

          // Classify content for this folder
          const classified = this.#classifyFolderContent(folder, folders, entries, {allowChildren});
          node.entries = classified.entries;
          node.children = classified.folders.map(folder => createNode(false, folder, depth));
          depths[depth].push(...node.children);

          // Update unassigned content
          folders = classified.unassignedFolders;
          entries = classified.unassignedEntries;
        }
      }

      // Populate left-over folders at the root level of the tree
      for ( const folder of folders ) {
        const node = createNode(false, folder, 1);
        const classified = this.#classifyFolderContent(folder, folders, entries, {allowChildren: false});
        node.entries = classified.entries;
        entries = classified.unassignedEntries;
        depths[1].push(node);
      }

      // Populate left-over entries at the root level of the tree
      if ( entries.length ) {
        tree.entries.push(...entries);
      }

      // Sort the top level entries and folders
      const sort = this.sortingMode === "a" ? this.constructor._sortAlphabetical : this.constructor._sortStandard;
      tree.entries.sort(sort);
      tree.children.sort((a, b) => sort(a.folder, b.folder));

      // Recursively filter visibility of the tree
      const filterChildren = node => {
        node.children = node.children.filter(child => {
          filterChildren(child);
          return child.visible;
        });
        node.visible = node.root || game.user.isGM || ((node.children.length + node.entries.length) > 0);

        // Populate some attributes of the Folder document
        if ( node.folder ) {
          node.folder.displayed = node.visible;
          node.folder.depth = node.depth;
          node.folder.children = node.children;
        }
      };
      filterChildren(tree);
      return tree;
    }

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

    /**
     * Creates the list of Folder options in this Collection in hierarchical order
     * for populating the options of a select tag.
     * @returns {{id: string, name: string}[]}
     * @internal
     */
    _formatFolderSelectOptions() {
      const options = [];
      const traverse = node => {
        if ( !node ) return;
        const folder = node.folder;
        if ( folder?.visible ) options.push({
          id: folder.id,
          name: `${"─".repeat(folder.depth - 1)} ${folder.name}`.trim()
        });
        node.children.forEach(traverse);
      };
      traverse(this.tree);
      return options;
    }

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

    /**
     * Populate a single folder with child folders and content
     * This method is called recursively when building the folder tree
     * @param {Folder|null} folder                    A parent folder being populated or null for the root node
     * @param {Folder[]} folders                      Remaining unassigned folders which may be children of this one
     * @param {Object[]} entries                      Remaining unassigned entries which may be children of this one
     * @param {object} [options={}]                   Options which configure population
     * @param {boolean} [options.allowChildren=true]  Allow additional child folders
     */
    #classifyFolderContent(folder, folders, entries, {allowChildren = true} = {}) {
      const sort = folder?.sorting === "a" ? this.constructor._sortAlphabetical : this.constructor._sortStandard;

      // Determine whether an entry belongs to a folder, via folder ID or folder reference
      function folderMatches(entry) {
        if ( entry.folder?._id ) return entry.folder._id === folder?._id;
        return (entry.folder === folder) || (entry.folder === folder?._id);
      }

      // Partition folders into children and unassigned folders
      const [unassignedFolders, subfolders] = folders.partition(f => allowChildren && folderMatches(f));
      subfolders.sort(sort);

      // Partition entries into folder contents and unassigned entries
      const [unassignedEntries, contents] = entries.partition(e => folderMatches(e));
      contents.sort(sort);

      // Return the classified content
      return {folders: subfolders, entries: contents, unassignedFolders, unassignedEntries};
    }

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

    /**
     * Sort two Entries by name, alphabetically.
     * @param {Object} a    Some Entry
     * @param {Object} b    Some other Entry
     * @returns {number}    The sort order between entries a and b
     * @protected
     */
    static _sortAlphabetical(a, b) {
      if ( a.name === undefined ) throw new Error(`Missing name property for ${a.constructor.name} ${a.id}`);
      if ( b.name === undefined ) throw new Error(`Missing name property for ${b.constructor.name} ${b.id}`);
      return a.name.localeCompare(b.name, game.i18n.lang);
    }

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

    /**
     * Sort two Entries using their numeric sort fields.
     * @param {Object} a    Some Entry
     * @param {Object} b    Some other Entry
     * @returns {number}    The sort order between Entries a and b
     * @protected
     */
    static _sortStandard(a, b) {
      if ( a.sort === undefined ) throw new Error(`Missing sort property for ${a.constructor.name} ${a.id}`);
      if ( b.sort === undefined ) throw new Error(`Missing sort property for ${b.constructor.name} ${b.id}`);
      return a.sort - b.sort;
    }
  }
}

/**
 * An abstract subclass of the Collection container which defines a collection of Document instances.
 * @extends {Collection}
 * @abstract
 *
 * @param {object[]} data      An array of data objects from which to create document instances
 */
class DocumentCollection extends foundry.utils.Collection {
  constructor(data=[]) {
    super();

    /**
     * The source data array from which the Documents in the WorldCollection are created
     * @type {object[]}
     * @private
     */
    Object.defineProperty(this, "_source", {
      value: data,
      writable: false
    });

    /**
     * An Array of application references which will be automatically updated when the collection content changes
     * @type {Application[]}
     */
    this.apps = [];

    // Initialize data
    this._initialize();
  }

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

  /**
   * Initialize the DocumentCollection by constructing any initially provided Document instances
   * @private
   */
  _initialize() {
    this.clear();
    for ( let d of this._source ) {
      let doc;
      if ( game.issues ) game.issues._countDocumentSubType(this.documentClass, d);
      try {
        doc = this.documentClass.fromSource(d, {strict: true, dropInvalidEmbedded: true});
        super.set(doc.id, doc);
      } catch(err) {
        this.invalidDocumentIds.add(d._id);
        if ( game.issues ) game.issues._trackValidationFailure(this, d, err);
        Hooks.onError(`${this.constructor.name}#_initialize`, err, {
          msg: `Failed to initialize ${this.documentName} [${d._id}]`,
          log: "error",
          id: d._id
        });
      }
    }
  }

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

  /**
   * A reference to the Document class definition which is contained within this DocumentCollection.
   * @type {typeof foundry.abstract.Document}
   */
  get documentClass() {
    return getDocumentClass(this.documentName);
  }

  /** @inheritdoc */
  get documentName() {
    const name = this.constructor.documentName;
    if ( !name ) throw new Error("A subclass of DocumentCollection must define its static documentName");
    return name;
  }

  /**
   * The base Document type which is contained within this DocumentCollection
   * @type {string}
   */
  static documentName;

  /**
   * Record the set of document ids where the Document was not initialized because of invalid source data
   * @type {Set<string>}
   */
  invalidDocumentIds = new Set();

  /**
   * The Collection class name
   * @type {string}
   */
  get name() {
    return this.constructor.name;
  }

  /* -------------------------------------------- */
  /*  Collection Methods                          */
  /* -------------------------------------------- */

  /**
   * Instantiate a Document for inclusion in the Collection.
   * @param {object} data       The Document data.
   * @param {object} [context]  Document creation context.
   * @returns {foundry.abstract.Document}
   */
  createDocument(data, context={}) {
    return new this.documentClass(data, context);
  }

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

  /**
   * Obtain a temporary Document instance for a document id which currently has invalid source data.
   * @param {string} id                      A document ID with invalid source data.
   * @param {object} [options]               Additional options to configure retrieval.
   * @param {boolean} [options.strict=true]  Throw an Error if the requested ID is not in the set of invalid IDs for
   *                                         this collection.
   * @returns {Document}                     An in-memory instance for the invalid Document
   * @throws If strict is true and the requested ID is not in the set of invalid IDs for this collection.
   */
  getInvalid(id, {strict=true}={}) {
    if ( !this.invalidDocumentIds.has(id) ) {
      if ( strict ) throw new Error(`${this.constructor.documentName} id [${id}] is not in the set of invalid ids`);
      return;
    }
    const data = this._source.find(d => d._id === id);
    return this.documentClass.fromSource(foundry.utils.deepClone(data));
  }

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

  /**
   * Get an element from the DocumentCollection by its ID.
   * @param {string} id                        The ID of the Document to retrieve.
   * @param {object} [options]                 Additional options to configure retrieval.
   * @param {boolean} [options.strict=false]   Throw an Error if the requested Document does not exist.
   * @param {boolean} [options.invalid=false]  Allow retrieving an invalid Document.
   * @returns {foundry.abstract.Document}
   * @throws If strict is true and the Document cannot be found.
   */
  get(id, {invalid=false, strict=false}={}) {
    let result = super.get(id);
    if ( !result && invalid ) result = this.getInvalid(id, { strict: false });
    if ( !result && strict ) throw new Error(`${this.constructor.documentName} id [${id}] does not exist in the `
      + `${this.constructor.name} collection.`);
    return result;
  }

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

  /** @inheritdoc */
  set(id, document) {
    const cls = this.documentClass;
    if (!(document instanceof cls)) {
      throw new Error(`You may only push instances of ${cls.documentName} to the ${this.name} collection`);
    }
    const replacement = this.has(document.id);
    super.set(document.id, document);
    if ( replacement ) this._source.findSplice(e => e._id === id, document.toObject());
    else this._source.push(document.toObject());
  }

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

  /** @inheritdoc */
  delete(id) {
    super.delete(id);
    const removed = this._source.findSplice(e => e._id === id);
    return !!removed;
  }

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

  /**
   * Render any Applications associated with this DocumentCollection.
   */
  render(force, options) {
    for (let a of this.apps) a.render(force, options);
  }

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

  /**
   * The cache of search fields for each data model
   * @type {Map<string, Set<string>>}
   */
  static #dataModelSearchFieldsCache = new Map();

  /**
   * Get the searchable fields for a given document or index, based on its data model
   * @param {string} documentName         The document type name
   * @param {string} [documentSubtype=""] The document subtype name
   * @param {boolean} [isEmbedded=false]  Whether the document is an embedded object
   * @returns {Set<string>}               The dot-delimited property paths of searchable fields
   */
  static getSearchableFields(documentName, documentSubtype="", isEmbedded=false) {
    const isSubtype = !!documentSubtype;
    const cacheName = isSubtype ? `${documentName}.${documentSubtype}` : documentName;

    // If this already exists in the cache, return it
    if ( DocumentCollection.#dataModelSearchFieldsCache.has(cacheName) ) {
      return DocumentCollection.#dataModelSearchFieldsCache.get(cacheName);
    }

    // Load the Document DataModel
    const docConfig = CONFIG[documentName];
    if ( !docConfig ) throw new Error(`Could not find configuration for ${documentName}`);

    // Read the fields that can be searched from the Data Model
    const textSearchFields = new Set(isSubtype ? this.getSearchableFields(documentName) : []);
    const dataModel = isSubtype ? docConfig.dataModels?.[documentSubtype] : docConfig.documentClass;
    dataModel?.schema.apply(function() {
      if ( (this instanceof foundry.data.fields.StringField) && this.textSearch ) {
        // Non-TypeDataModel sub-types may produce an incorrect field path, in which case we prepend "system."
        textSearchFields.add(isSubtype && !dataModel.schema.name ? `system.${this.fieldPath}` : this.fieldPath);
      }
    });

    // Cache the result
    DocumentCollection.#dataModelSearchFieldsCache.set(cacheName, textSearchFields);

    return textSearchFields;
  }

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

  /**
   * Find all Documents which match a given search term using a full-text search against their indexed HTML fields and their name.
   * If filters are provided, results are filtered to only those that match the provided values.
   * @param {object} search                      An object configuring the search
   * @param {string} [search.query]              A case-insensitive search string
   * @param {FieldFilter[]} [search.filters]     An array of filters to apply
   * @param {string[]} [search.exclude]          An array of document IDs to exclude from search results
   * @returns {string[]}
   */
  search({query= "", filters=[], exclude=[]}) {
    query = SearchFilter.cleanQuery(query);
    const regex = new RegExp(RegExp.escape(query), "i");
    const results = [];
    const hasFilters = !foundry.utils.isEmpty(filters);
    let domParser;
    for ( const doc of this.index ?? this.contents ) {
      if ( exclude.includes(doc._id) ) continue;
      let isMatch = !query;

      // Do a full-text search against any searchable fields based on metadata
      if ( query ) {
        const textSearchFields = DocumentCollection.getSearchableFields(
          doc.constructor.documentName ?? this.documentName, doc.type, !!doc.parentCollection);
        for ( const fieldName of textSearchFields ) {
          let value = foundry.utils.getProperty(doc, fieldName);
          // Search the text context of HTML instead of the HTML
          if ( value ) {
            let field;
            if ( fieldName.startsWith("system.") ) {
              if ( doc.system instanceof foundry.abstract.DataModel ) {
                field = doc.system.schema.getField(fieldName.slice(7));
              }
            } else field = doc.schema.getField(fieldName);
            if ( field instanceof foundry.data.fields.HTMLField ) {
              // TODO: Ideally we would search the text content of the enriched HTML: can we make that happen somehow?
              domParser ??= new DOMParser();
              value = domParser.parseFromString(value, "text/html").body.textContent;
            }
          }
          if ( value && regex.test(SearchFilter.cleanQuery(value)) ) {
            isMatch = true;
            break; // No need to evaluate other fields, we already know this is a match
          }
        }
      }

      // Apply filters
      if ( hasFilters ) {
        for ( const filter of filters ) {
          if ( !SearchFilter.evaluateFilter(doc, filter) ) {
            isMatch = false;
            break; // No need to evaluate other filters, we already know this is not a match
          }
        }
      }

      if ( isMatch ) results.push(doc);
    }

    return results;
  }

  /* -------------------------------------------- */
  /*  Database Operations                         */
  /* -------------------------------------------- */

  /**
   * Update all objects in this DocumentCollection with a provided transformation.
   * Conditionally filter to only apply to Entities which match a certain condition.
   * @param {Function|object} transformation    An object of data or function to apply to all matched objects
   * @param {Function|null}  condition          A function which tests whether to target each object
   * @param {object} [options]                  Additional options passed to Document.updateDocuments
   * @returns {Promise<Document[]>}             An array of updated data once the operation is complete
   */
  async updateAll(transformation, condition=null, options={}) {
    const hasTransformer = transformation instanceof Function;
    if ( !hasTransformer && (foundry.utils.getType(transformation) !== "Object") ) {
      throw new Error("You must provide a data object or transformation function");
    }
    const hasCondition = condition instanceof Function;
    const updates = [];
    for ( let doc of this ) {
      if ( hasCondition && !condition(doc) ) continue;
      const update = hasTransformer ? transformation(doc) : foundry.utils.deepClone(transformation);
      update._id = doc.id;
      updates.push(update);
    }
    return this.documentClass.updateDocuments(updates, options);
  }

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

  /**
   * Follow-up actions to take when a database operation modifies Documents in this DocumentCollection.
   * @param {DatabaseAction} action                   The database action performed
   * @param {ClientDocument[]} documents              The array of modified Documents
   * @param {any[]} result                            The result of the database operation
   * @param {DatabaseOperation} operation             Database operation details
   * @param {User} user                               The User who performed the operation
   * @internal
   */
  _onModifyContents(action, documents, result, operation, user) {
    if ( operation.render ) {
      this.render(false, {renderContext: `${action}${this.documentName}`, renderData: result});
    }
  }
}

/**
 * A collection of world-level Document objects with a singleton instance per primary Document type.
 * Each primary Document type has an associated subclass of WorldCollection which contains them.
 * @extends {DocumentCollection}
 * @abstract
 * @see {Game#collections}
 *
 * @param {object[]} data      An array of data objects from which to create Document instances
 */
class WorldCollection extends DirectoryCollectionMixin(DocumentCollection) {
  /* -------------------------------------------- */
  /*  Collection Properties                       */
  /* -------------------------------------------- */

  /**
   * Reference the set of Folders which contain documents in this collection
   * @type {Collection<string, Folder>}
   */
  get folders() {
    return game.folders.reduce((collection, folder) => {
      if (folder.type === this.documentName) {
        collection.set(folder.id, folder);
      }
      return collection;
    }, new foundry.utils.Collection());
  }

  /**
   * Return a reference to the SidebarDirectory application for this WorldCollection.
   * @type {DocumentDirectory}
   */
  get directory() {
    const doc = getDocumentClass(this.constructor.documentName);
    return ui[doc.metadata.collection];
  }

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

  /**
   * Return a reference to the singleton instance of this WorldCollection, or null if it has not yet been created.
   * @type {WorldCollection}
   */
  static get instance() {
    return game.collections.get(this.documentName);
  }

  /* -------------------------------------------- */
  /*  Collection Methods                          */
  /* -------------------------------------------- */

  /** @override */
  _getVisibleTreeContents(entry) {
    return this.contents.filter(c => c.visible);
  }

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

  /**
   * Import a Document from a Compendium collection, adding it to the current World.
   * @param {CompendiumCollection} pack The CompendiumCollection instance from which to import
   * @param {string} id             The ID of the compendium entry to import
   * @param {object} [updateData]   Optional additional data used to modify the imported Document before it is created
   * @param {object} [options]      Optional arguments passed to the {@link WorldCollection#fromCompendium} and
   *                                {@link Document.create} methods
   * @returns {Promise<Document>}   The imported Document instance
   */
  async importFromCompendium(pack, id, updateData={}, options={}) {
    const cls = this.documentClass;
    if (pack.documentName !== cls.documentName) {
      throw new Error(`The ${pack.documentName} Document type provided by Compendium ${pack.collection} is incorrect for this Collection`);
    }

    // Prepare the source data from which to create the Document
    const document = await pack.getDocument(id);
    const sourceData = this.fromCompendium(document, options);
    const createData = foundry.utils.mergeObject(sourceData, updateData);

    // Create the Document
    console.log(`${vtt} | Importing ${cls.documentName} ${document.name} from ${pack.collection}`);
    this.directory.activate();
    options.fromCompendium = true;
    return this.documentClass.create(createData, options);
  }

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

  /**
   * @typedef {object} FromCompendiumOptions
   * @property {boolean} [options.clearFolder=false]    Clear the currently assigned folder.
   * @property {boolean} [options.clearSort=true]       Clear the current sort order.
   * @property {boolean} [options.clearOwnership=true]  Clear Document ownership.
   * @property {boolean} [options.keepId=false]         Retain the Document ID from the source Compendium.
   */

  /**
   * Apply data transformations when importing a Document from a Compendium pack
   * @param {Document|object} document         The source Document, or a plain data object
   * @param {FromCompendiumOptions} [options]  Additional options which modify how the document is imported
   * @returns {object}                         The processed data ready for world Document creation
   */
  fromCompendium(document, {clearFolder=false, clearSort=true, clearOwnership=true, keepId=false, ...rest}={}) {
    /** @deprecated since v12 */
    if ( "addFlags" in rest ) {
      foundry.utils.logCompatibilityWarning("The addFlags option for WorldCompendium#fromCompendium has been removed. ",
        { since: 12, until: 14 });
    }

    // Prepare the data structure
    let data = document;
    if (document instanceof foundry.abstract.Document) {
      data = document.toObject();
      if ( document.pack ) foundry.utils.setProperty(data, "_stats.compendiumSource", document.uuid);
    }

    // Eliminate certain fields
    if ( !keepId ) delete data._id;
    if ( clearFolder ) delete data.folder;
    if ( clearSort ) delete data.sort;
    if ( clearOwnership && ("ownership" in data) ) {
      data.ownership = {
        default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE,
        [game.user.id]: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER
      };
    }
    return data;
  }

  /* -------------------------------------------- */
  /*  Sheet Registration Methods                  */
  /* -------------------------------------------- */

  /**
   * Register a Document sheet class as a candidate which can be used to display Documents of a given type.
   * See {@link DocumentSheetConfig.registerSheet} for details.
   * @static
   * @param {Array<*>} args      Arguments forwarded to the DocumentSheetConfig.registerSheet method
   *
   * @example Register a new ActorSheet subclass for use with certain Actor types.
   * ```js
   * Actors.registerSheet("dnd5e", ActorSheet5eCharacter, { types: ["character], makeDefault: true });
   * ```
   */
  static registerSheet(...args) {
    DocumentSheetConfig.registerSheet(getDocumentClass(this.documentName), ...args);
  }

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

  /**
   * Unregister a Document sheet class, removing it from the list of available sheet Applications to use.
   * See {@link DocumentSheetConfig.unregisterSheet} for detauls.
   * @static
   * @param {Array<*>} args      Arguments forwarded to the DocumentSheetConfig.unregisterSheet method
   *
   * @example Deregister the default ActorSheet subclass to replace it with others.
   * ```js
   * Actors.unregisterSheet("core", ActorSheet);
   * ```
   */
  static unregisterSheet(...args) {
    DocumentSheetConfig.unregisterSheet(getDocumentClass(this.documentName), ...args);
  }

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

  /**
   * Return an array of currently registered sheet classes for this Document type.
   * @static
   * @type {DocumentSheet[]}
   */
  static get registeredSheets() {
    const sheets = new Set();
    for ( let t of Object.values(CONFIG[this.documentName].sheetClasses) ) {
      for ( let s of Object.values(t) ) {
        sheets.add(s.cls);
      }
    }
    return Array.from(sheets);
  }
}

/**
 * The singleton collection of Actor documents which exist within the active World.
 * This Collection is accessible within the Game object as game.actors.
 * @extends {WorldCollection}
 * @category - Collections
 *
 * @see {@link Actor} The Actor document
 * @see {@link ActorDirectory} The ActorDirectory sidebar directory
 *
 * @example Retrieve an existing Actor by its id
 * ```js
 * let actor = game.actors.get(actorId);
 * ```
 */
class Actors extends WorldCollection {
  /**
   * A mapping of synthetic Token Actors which are currently active within the viewed Scene.
   * Each Actor is referenced by the Token.id.
   * @type {Record<string, Actor>}
   */
  get tokens() {
    if ( !canvas.ready || !canvas.scene ) return {};
    return canvas.scene.tokens.reduce((obj, t) => {
      if ( t.actorLink ) return obj;
      obj[t.id] = t.actor;
      return obj;
    }, {});
  }

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

  /** @override */
  static documentName = "Actor";

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

  /**
   * @param {Document|object} document
   * @param {FromCompendiumOptions} [options]
   * @param {boolean} [options.clearPrototypeToken=true]  Clear prototype token data to allow default token settings to
   *                                                      be applied.
   * @returns {object}
   */
  fromCompendium(document, options={}) {
    const data = super.fromCompendium(document, options);

    // Clear prototype token data.
    if ( (options.clearPrototypeToken !== false) && ("prototypeToken" in data) ) {
      const settings = game.settings.get("core", DefaultTokenConfig.SETTING) ?? {};
      foundry.data.PrototypeToken.schema.apply(function(v) {
        if ( typeof v !== "object" ) foundry.utils.setProperty(data.prototypeToken, this.fieldPath, undefined);
      }, settings, { partial: true });
    }

    // Re-associate imported Active Effects which are sourced to Items owned by this same Actor
    if ( data._id ) {
      const ownItemIds = new Set(data.items.map(i => i._id));
      for ( let effect of data.effects ) {
        if ( !effect.origin ) continue;
        const effectItemId = effect.origin.split(".").pop();
        if ( ownItemIds.has(effectItemId) ) {
          effect.origin = `Actor.${data._id}.Item.${effectItemId}`;
        }
      }
    }
    return data;
  }
}

/**
 * The collection of Cards documents which exist within the active World.
 * This Collection is accessible within the Game object as game.cards.
 * @extends {WorldCollection}
 * @see {@link Cards} The Cards document
 */
class CardStacks extends WorldCollection {

  /** @override */
  static documentName = "Cards";
}

/**
 * The singleton collection of Combat documents which exist within the active World.
 * This Collection is accessible within the Game object as game.combats.
 * @extends {WorldCollection}
 *
 * @see {@link Combat} The Combat document
 * @see {@link CombatTracker} The CombatTracker sidebar directory
 */
class CombatEncounters extends WorldCollection {

  /** @override */
  static documentName = "Combat";

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

  /**
   * Provide the settings object which configures the Combat document
   * @type {object}
   */
  static get settings() {
    return game.settings.get("core", Combat.CONFIG_SETTING);
  }

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

  /** @inheritdoc */
  get directory() {
    return ui.combat;
  }

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

  /**
   * Get an Array of Combat instances which apply to the current canvas scene
   * @type {Combat[]}
   */
  get combats() {
    return this.filter(c => (c.scene === null) || (c.scene === game.scenes.current));
  }

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

  /**
   * The currently active Combat instance
   * @type {Combat}
   */
  get active() {
    return this.combats.find(c => c.active);
  }

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

  /**
   * The currently viewed Combat encounter
   * @type {Combat|null}
   */
  get viewed() {
    return ui.combat?.viewed ?? null;
  }

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

  /**
   * When a Token is deleted, remove it as a combatant from any combat encounters which included the Token
   * @param {string} sceneId      The Scene id within which a Token is being deleted
   * @param {string} tokenId      The Token id being deleted
   * @protected
   */
  async _onDeleteToken(sceneId, tokenId) {
    for ( let combat of this ) {
      const toDelete = [];
      for ( let c of combat.combatants ) {
        if ( (c.sceneId === sceneId) && (c.tokenId === tokenId) ) toDelete.push(c.id);
      }
      if ( toDelete.length ) await combat.deleteEmbeddedDocuments("Combatant", toDelete);
    }
  }
}

/**
 * @typedef {SocketRequest} ManageCompendiumRequest
 * @property {string} action                      The request action.
 * @property {PackageCompendiumData|string} data  The compendium creation data, or the ID of the compendium to delete.
 * @property {object} [options]                   Additional options.
 */

/**
 * @typedef {SocketResponse} ManageCompendiumResponse
 * @property {ManageCompendiumRequest} request      The original request.
 * @property {PackageCompendiumData|string} result  The compendium creation data, or the collection name of the
 *                                                  deleted compendium.
 */

/**
 * A collection of Document objects contained within a specific compendium pack.
 * Each Compendium pack has its own associated instance of the CompendiumCollection class which contains its contents.
 * @extends {DocumentCollection}
 * @abstract
 * @see {Game#packs}
 *
 * @param {object} metadata   The compendium metadata, an object provided by game.data
 */
class CompendiumCollection extends DirectoryCollectionMixin(DocumentCollection) {
  constructor(metadata) {
    super([]);

    /**
     * The compendium metadata which defines the compendium content and location
     * @type {object}
     */
    this.metadata = metadata;

    /**
     * A subsidiary collection which contains the more minimal index of the pack
     * @type {Collection<string, object>}
     */
    this.index = new foundry.utils.Collection();

    /**
     * A subsidiary collection which contains the folders within the pack
     * @type {Collection<string, Folder>}
     */
    this.#folders = new CompendiumFolderCollection(this);

    /**
     * A debounced function which will clear the contents of the Compendium pack if it is not accessed frequently.
     * @type {Function}
     * @private
     */
    this._flush = foundry.utils.debounce(this.clear.bind(this), this.constructor.CACHE_LIFETIME_SECONDS * 1000);

    // Initialize a provided Compendium index
    this.#indexedFields = new Set(this.documentClass.metadata.compendiumIndexFields);
    for ( let i of metadata.index ) {
      i.uuid = this.getUuid(i._id);
      this.index.set(i._id, i);
    }
    delete metadata.index;
    for ( let f of metadata.folders.sort((a, b) => a.sort - b.sort) ) {
      this.#folders.set(f._id, new Folder.implementation(f, {pack: this.collection}));
    }
    delete metadata.folders;
  }

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

  /**
   * The amount of time that Document instances within this CompendiumCollection are held in memory.
   * Accessing the contents of the Compendium pack extends the duration of this lifetime.
   * @type {number}
   */
  static CACHE_LIFETIME_SECONDS = 300;

  /**
   * The named game setting which contains Compendium configurations.
   * @type {string}
   */
  static CONFIG_SETTING = "compendiumConfiguration";

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

  /**
   * The canonical Compendium name - comprised of the originating package and the pack name
   * @type {string}
   */
  get collection() {
    return this.metadata.id;
  }

  /**
   * The banner image for this Compendium pack, or the default image for the pack type if no image is set.
   * @returns {string|null|void}
   */
  get banner() {
    if ( this.metadata.banner === undefined ) return CONFIG[this.metadata.type]?.compendiumBanner;
    return this.metadata.banner;
  }

  /**
   * A reference to the Application class which provides an interface to interact with this compendium content.
   * @type {typeof Application}
   */
  applicationClass = Compendium;

  /**
   * The set of Compendium Folders
   */
  #folders;

  get folders() {
    return this.#folders;
  }

  /** @override */
  get maxFolderDepth() {
    return super.maxFolderDepth - 1;
  }

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

  /**
   * Get the Folder that this Compendium is displayed within
   * @returns {Folder|null}
   */
  get folder() {
    return game.folders.get(this.config.folder) ?? null;
  }

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

  /**
   * Assign this CompendiumCollection to be organized within a specific Folder.
   * @param {Folder|string|null} folder     The desired Folder within the World or null to clear the folder
   * @returns {Promise<void>}               A promise which resolves once the transaction is complete
   */
  async setFolder(folder) {
    const current = this.config.folder;

    // Clear folder
    if ( folder === null ) {
      if ( current === null ) return;
      return this.configure({folder: null});
    }

    // Set folder
    if ( typeof folder === "string" ) folder = game.folders.get(folder);
    if ( !(folder instanceof Folder) ) throw new Error("You must pass a valid Folder or Folder ID.");
    if ( folder.type !== "Compendium" ) throw new Error(`Folder "${folder.id}" is not of the required Compendium type`);
    if ( folder.id === current ) return;
    await this.configure({folder: folder.id});
  }

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

  /**
   * Get the sort order for this Compendium
   * @returns {number}
   */
  get sort() {
    return this.config.sort ?? 0;
  }

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

  /** @override */
  _getVisibleTreeContents() {
    return this.index.contents;
  }

  /** @override */
  static _sortStandard(a, b) {
    return a.sort - b.sort;
  }

  /**
   * Access the compendium configuration data for this pack
   * @type {object}
   */
  get config() {
    const setting = game.settings.get("core", "compendiumConfiguration");
    const config = setting[this.collection] || {};
    /** @deprecated since v11 */
    if ( "private" in config ) {
      if ( config.private === true ) config.ownership = {PLAYER: "LIMITED", ASSISTANT: "OWNER"};
      delete config.private;
    }
    return config;
  }

  /** @inheritdoc */
  get documentName() {
    return this.metadata.type;
  }

  /**
   * Track whether the Compendium Collection is locked for editing
   * @type {boolean}
   */
  get locked() {
    return this.config.locked ?? (this.metadata.packageType !== "world");
  }

  /**
   * The visibility configuration of this compendium pack.
   * @type {Record<CONST.USER_ROLES, CONST.DOCUMENT_OWNERSHIP_LEVELS>}
   */
  get ownership() {
    return this.config.ownership ?? this.metadata.ownership ?? {...Module.schema.getField("packs.ownership").initial};
  }

  /**
   * Is this Compendium pack visible to the current game User?
   * @type {boolean}
   */
  get visible() {
    return this.getUserLevel() >= CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER;
  }

  /**
   * A convenience reference to the label which should be used as the title for the Compendium pack.
   * @type {string}
   */
  get title() {
    return this.metadata.label;
  }

  /**
   * The index fields which should be loaded for this compendium pack
   * @type {Set<string>}
   */
  get indexFields() {
    const coreFields = this.documentClass.metadata.compendiumIndexFields;
    const configFields = CONFIG[this.documentName].compendiumIndexFields || [];
    return new Set([...coreFields, ...configFields]);
  }

  /**
   * Track which document fields have been indexed for this compendium pack
   * @type {Set<string>}
   * @private
   */
  #indexedFields;

  /**
   * Has this compendium pack been fully indexed?
   * @type {boolean}
   */
  get indexed() {
    return this.indexFields.isSubset(this.#indexedFields);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  get(key, options) {
    this._flush();
    return super.get(key, options);
  }

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

  /** @inheritdoc */
  set(id, document) {
    if ( document instanceof Folder ) {
      return this.#folders.set(id, document);
    }
    this._flush();
    this.indexDocument(document);
    return super.set(id, document);
  }

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

  /** @inheritdoc */
  delete(id) {
    this.index.delete(id);
    return super.delete(id);
  }

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

  /** @inheritDoc */
  clear() {
    for ( const doc of this.values() ) {
      if ( !Object.values(doc.apps).some(app => app.rendered) ) super.delete(doc.id);
    }
  }

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

  /**
   * Load the Compendium index and cache it as the keys and values of the Collection.
   * @param {object} [options]    Options which customize how the index is created
   * @param {string[]} [options.fields]  An array of fields to return as part of the index
   * @returns {Promise<Collection>}
   */
  async getIndex({fields=[]}={}) {
    const cls = this.documentClass;

    // Maybe reuse the existing index if we have already indexed all fields
    const indexFields = new Set([...this.indexFields, ...fields]);
    if ( indexFields.isSubset(this.#indexedFields) ) return this.index;

    // Request the new index from the server
    const index = await cls.database.get(cls, {
      query: {},
      index: true,
      indexFields: Array.from(indexFields),
      pack: this.collection
    }, game.user);

    // Assign the index to the collection
    for ( let i of index ) {
      const x = this.index.get(i._id);
      const indexed = x ? foundry.utils.mergeObject(x, i) : i;
      indexed.uuid = this.getUuid(indexed._id);
      this.index.set(i._id, indexed);
    }

    // Record that the pack has been indexed
    console.log(`${vtt} | Constructed index of ${this.collection} Compendium containing ${this.index.size} entries`);
    this.#indexedFields = indexFields;
    return this.index;
  }

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

  /**
   * Get a single Document from this Compendium by ID.
   * The document may already be locally cached, otherwise it is retrieved from the server.
   * @param {string} id               The requested Document id
   * @returns {Promise<Document>|undefined}     The retrieved Document instance
   */
  async getDocument(id) {
    if ( !id ) return undefined;
    const cached = this.get(id);
    if ( cached instanceof foundry.abstract.Document ) return cached;
    const documents = await this.getDocuments({_id: id});
    return documents.length ? documents.shift() : null;
  }

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

  /**
   * Load multiple documents from the Compendium pack using a provided query object.
   * @param {object} query            A database query used to retrieve documents from the underlying database
   * @returns {Promise<Document[]>}   The retrieved Document instances
   *
   * @example Get Documents that match the given value only.
   * ```js
   * await pack.getDocuments({ type: "weapon" });
   * ```
   *
   * @example Get several Documents by their IDs.
   * ```js
   * await pack.getDocuments({ _id__in: arrayOfIds });
   * ```
   *
   * @example Get Documents by their sub-types.
   * ```js
   * await pack.getDocuments({ type__in: ["weapon", "armor"] });
   * ```
   */
  async getDocuments(query={}) {
    const cls = this.documentClass;
    const documents = await cls.database.get(cls, {query, pack: this.collection}, game.user);
    for ( let d of documents ) {
      if ( d.invalid && !this.invalidDocumentIds.has(d.id) ) {
        this.invalidDocumentIds.add(d.id);
        this._source.push(d);
      }
      else this.set(d.id, d);
    }
    return documents;
  }

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

  /**
   * Get the ownership level that a User has for this Compendium pack.
   * @param {documents.User} user     The user being tested
   * @returns {number}                The ownership level in CONST.DOCUMENT_OWNERSHIP_LEVELS
   */
  getUserLevel(user=game.user) {
    const levels = CONST.DOCUMENT_OWNERSHIP_LEVELS;
    let level = levels.NONE;
    for ( const [role, l] of Object.entries(this.ownership) ) {
      if ( user.hasRole(role) ) level = Math.max(level, levels[l]);
    }
    return level;
  }

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

  /**
   * Test whether a certain User has a requested permission level (or greater) over the Compendium pack
   * @param {documents.BaseUser} user       The User being tested
   * @param {string|number} permission      The permission level from DOCUMENT_OWNERSHIP_LEVELS to test
   * @param {object} options                Additional options involved in the permission test
   * @param {boolean} [options.exact=false]     Require the exact permission level requested?
   * @returns {boolean}                      Does the user have this permission level over the Compendium pack?
   */
  testUserPermission(user, permission, {exact=false}={}) {
    const perms = CONST.DOCUMENT_OWNERSHIP_LEVELS;
    const level = user.isGM ? perms.OWNER : this.getUserLevel(user);
    const target = (typeof permission === "string") ? (perms[permission] ?? perms.OWNER) : permission;
    return exact ? level === target : level >= target;
  }

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

  /**
   * Import a Document into this Compendium Collection.
   * @param {Document} document     The existing Document you wish to import
   * @param {object} [options]      Additional options which modify how the data is imported.
   *                                See {@link ClientDocumentMixin#toCompendium}
   * @returns {Promise<Document>}   The imported Document instance
   */
  async importDocument(document, options={}) {
    if ( !(document instanceof this.documentClass) && !(document instanceof Folder) ) {
      const err = Error(`You may not import a ${document.constructor.name} Document into the ${this.collection} Compendium which contains ${this.documentClass.name} Documents.`);
      ui.notifications.error(err.message);
      throw err;
    }
    options.clearOwnership = options.clearOwnership ?? (this.metadata.packageType === "world");
    const data = document.toCompendium(this, options);

    return document.constructor.create(data, {pack: this.collection});
  }

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

  /**
   * Import a Folder into this Compendium Collection.
   * @param {Folder} folder                         The existing Folder you wish to import
   * @param {object} [options]                      Additional options which modify how the data is imported.
   * @param {boolean} [options.importParents=true]  Import any parent folders which are not already present in the Compendium
   * @returns {Promise<void>}
   */
  async importFolder(folder, {importParents=true, ...options}={}) {
    if ( !(folder instanceof Folder) ) {
      const err = Error(`You may not import a ${folder.constructor.name} Document into the folders collection of the ${this.collection} Compendium.`);
      ui.notifications.error(err.message);
      throw err;
    }

    const toCreate = [folder];
    if ( importParents ) toCreate.push(...folder.getParentFolders().filter(f => !this.folders.has(f.id)));
    await Folder.createDocuments(toCreate, {pack: this.collection, keepId: true});
  }

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

  /**
   * Import an array of Folders into this Compendium Collection.
   * @param {Folder[]} folders                      The existing Folders you wish to import
   * @param {object} [options]                      Additional options which modify how the data is imported.
   * @param {boolean} [options.importParents=true]  Import any parent folders which are not already present in the Compendium
   * @returns {Promise<void>}
   */
  async importFolders(folders, {importParents=true, ...options}={}) {
    if ( folders.some(f => !(f instanceof Folder)) ) {
      const err = Error(`You can only import Folder documents into the folders collection of the ${this.collection} Compendium.`);
      ui.notifications.error(err.message);
      throw err;
    }

    const toCreate = new Set(folders);
    if ( importParents ) {
      for ( const f of folders ) {
        for ( const p of f.getParentFolders() ) {
          if ( !this.folders.has(p.id) ) toCreate.add(p);
        }
      }
    }
    await Folder.createDocuments(Array.from(toCreate), {pack: this.collection, keepId: true});
  }

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

  /**
   * Fully import the contents of a Compendium pack into a World folder.
   * @param {object} [options={}]     Options which modify the import operation. Additional options are forwarded to
   *                                  {@link WorldCollection#fromCompendium} and {@link Document.createDocuments}
   * @param {string|null} [options.folderId]  An existing Folder _id to use.
   * @param {string} [options.folderName]     A new Folder name to create.
   * @returns {Promise<Document[]>}   The imported Documents, now existing within the World
   */
  async importAll({folderId=null, folderName="", ...options}={}) {
    let parentFolder;

    // Optionally, create a top level folder
    if ( CONST.FOLDER_DOCUMENT_TYPES.includes(this.documentName) ) {

      // Re-use an existing folder
      if ( folderId ) parentFolder = game.folders.get(folderId, {strict: true});

      // Create a new Folder
      if ( !parentFolder ) {
        parentFolder = await Folder.create({
          name: folderName || this.title,
          type: this.documentName,
          parent: null,
          color: this.folder?.color ?? null
        });
      }
    }

    // Load all content
    const folders = this.folders;
    const documents = await this.getDocuments();
    ui.notifications.info(game.i18n.format("COMPENDIUM.ImportAllStart", {
      number: documents.length,
      folderNumber: folders.size,
      type: game.i18n.localize(this.documentClass.metadata.label),
      folder: parentFolder.name
    }));

    // Create any missing Folders
    const folderCreateData = folders.map(f => {
        if ( game.folders.has(f.id) ) return null;
        const data = f.toObject();

        // If this folder has no parent folder, assign it to the new folder
        if ( !data.folder ) data.folder = parentFolder.id;
        return data;
    }).filter(f => f);
    await Folder.createDocuments(folderCreateData, {keepId: true});

    // Prepare import data
    const collection = game.collections.get(this.documentName);
    const createData = documents.map(doc => {
      const data = collection.fromCompendium(doc, options);

      // If this document has no folder, assign it to the new folder
      if ( !data.folder) data.folder = parentFolder.id;
      return data;
    });

    // Create World Documents in batches
    const chunkSize = 100;
    const nBatches = Math.ceil(createData.length / chunkSize);
    let created = [];
    for ( let n=0; n<nBatches; n++ ) {
      const chunk = createData.slice(n*chunkSize, (n+1)*chunkSize);
      const docs = await this.documentClass.createDocuments(chunk, options);
      created = created.concat(docs);
    }

    // Notify of success
    ui.notifications.info(game.i18n.format("COMPENDIUM.ImportAllFinish", {
      number: created.length,
      folderNumber: folders.size,
      type: game.i18n.localize(this.documentClass.metadata.label),
      folder: parentFolder.name
    }));
    return created;
  }

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

  /**
   * Provide a dialog form that prompts the user to import the full contents of a Compendium pack into the World.
   * @param {object} [options={}] Additional options passed to the Dialog.confirm method
   * @returns {Promise<Document[]|boolean|null>} A promise which resolves in the following ways: an array of imported
   *                            Documents if the "yes" button was pressed, false if the "no" button was pressed, or
   *                            null if the dialog was closed without making a choice.
   */
  async importDialog(options={}) {

    // Render the HTML form
    const collection = CONFIG[this.documentName]?.collection?.instance;
    const html = await renderTemplate("templates/sidebar/apps/compendium-import.html", {
      folderName: this.title,
      keepId: options.keepId ?? false,
      folders: collection?._formatFolderSelectOptions() ?? []
    });

    // Present the Dialog
    options.jQuery = false;
    return Dialog.confirm({
      title: `${game.i18n.localize("COMPENDIUM.ImportAll")}: ${this.title}`,
      content: html,
      render: html => {
        const form = html.querySelector("form");
        form.elements.folder.addEventListener("change", event => {
          form.elements.folderName.disabled = !!event.currentTarget.value;
        }, { passive: true });
      },
      yes: html => {
        const form = html.querySelector("form");
        return this.importAll({
          folderId: form.elements.folder.value,
          folderName: form.folderName.value,
          keepId: form.keepId.checked
        });
      },
      options
    });
  }

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

  /**
   * Add a Document to the index, capturing its relevant index attributes
   * @param {Document} document       The document to index
   */
  indexDocument(document) {
    let index = this.index.get(document.id);
    const data = document.toObject();
    if ( index ) foundry.utils.mergeObject(index, data, {insertKeys: false, insertValues: false});
    else {
      index = this.#indexedFields.reduce((obj, field) => {
        foundry.utils.setProperty(obj, field, foundry.utils.getProperty(data, field));
        return obj;
      }, {});
    }
    index.img = data.thumb ?? data.img;
    index._id = data._id;
    index.uuid = document.uuid;
    this.index.set(document.id, index);
  }

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

  /**
   * Prompt the gamemaster with a dialog to configure ownership of this Compendium pack.
   * @returns {Promise<Record<string, string>>}   The configured ownership for the pack
   */
  async configureOwnershipDialog() {
    if ( !game.user.isGM ) throw new Error("You do not have permission to configure ownership for this Compendium pack");
    const current = this.ownership;
    const levels = {
      "": game.i18n.localize("COMPENDIUM.OwnershipInheritBelow"),
      NONE: game.i18n.localize("OWNERSHIP.NONE"),
      LIMITED: game.i18n.localize("OWNERSHIP.LIMITED"),
      OBSERVER: game.i18n.localize("OWNERSHIP.OBSERVER"),
      OWNER: game.i18n.localize("OWNERSHIP.OWNER")
    };
    const roles = {
      ASSISTANT: {label: "USER.RoleAssistant", value: current.ASSISTANT, levels: { ...levels }},
      TRUSTED: {label: "USER.RoleTrusted", value: current.TRUSTED, levels: { ...levels }},
      PLAYER: {label: "USER.RolePlayer", value: current.PLAYER, levels: { ...levels }}
    };
    delete roles.PLAYER.levels[""];
    await Dialog.wait({
      title: `${game.i18n.localize("OWNERSHIP.Title")}: ${this.metadata.label}`,
      content: await renderTemplate("templates/sidebar/apps/compendium-ownership.hbs", {roles}),
      default: "ok",
      close: () => null,
      buttons: {
        reset: {
          label: game.i18n.localize("COMPENDIUM.OwnershipReset"),
          icon: '<i class="fas fa-undo"></i>',
          callback: () => this.configure({ ownership: undefined })
        },
        ok: {
          label: game.i18n.localize("OWNERSHIP.Configure"),
          icon: '<i class="fas fa-check"></i>',
          callback: async html => {
            const fd = new FormDataExtended(html.querySelector("form.compendium-ownership-dialog"));
            let ownership = Object.entries(fd.object).reduce((obj, [r, l]) => {
              if ( l ) obj[r] = l;
              return obj;
            }, {});
            ownership.GAMEMASTER = "OWNER";
            await this.configure({ownership});
          }
        }
      }
    }, { jQuery: false });
    return this.ownership;
  }

  /* -------------------------------------------- */
  /*  Compendium Management                       */
  /* -------------------------------------------- */

  /**
   * Activate the Socket event listeners used to receive responses to compendium management events.
   * @param {Socket} socket  The active game socket.
   * @internal
   */
  static _activateSocketListeners(socket) {
    socket.on("manageCompendium", response => {
      const { request } = response;
      switch ( request.action ) {
        case "create":
          CompendiumCollection.#handleCreateCompendium(response);
          break;
        case "delete":
          CompendiumCollection.#handleDeleteCompendium(response);
          break;
        default:
          throw new Error(`Invalid Compendium modification action ${request.action} provided.`);
      }
    });
  }

  /**
   * Create a new Compendium Collection using provided metadata.
   * @param {object} metadata   The compendium metadata used to create the new pack
   * @param {object} options   Additional options which modify the Compendium creation request
   * @returns {Promise<CompendiumCollection>}
   */
  static async createCompendium(metadata, options={}) {
    if ( !game.user.isGM ) return ui.notifications.error("You do not have permission to modify this compendium pack");
    const response = await SocketInterface.dispatch("manageCompendium", {
      action: "create",
      data: metadata,
      options: options
    });

    return this.#handleCreateCompendium(response);
  }

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

  /**
   * Generate a UUID for a given primary document ID within this Compendium pack
   * @param {string} id     The document ID to generate a UUID for
   * @returns {string}      The generated UUID, in the form of "Compendium.<collection>.<documentName>.<id>"
   */
  getUuid(id) {
    return `Compendium.${this.collection}.${this.documentName}.${id}`;
  }

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

  /**
   * Assign configuration metadata settings to the compendium pack
   * @param {object} configuration  The object of compendium settings to define
   * @returns {Promise}             A Promise which resolves once the setting is updated
   */
  configure(configuration={}) {
    const settings = game.settings.get("core", "compendiumConfiguration");
    const config = this.config;
    for ( const [k, v] of Object.entries(configuration) ) {
      if ( v === undefined ) delete config[k];
      else config[k] = v;
    }
    settings[this.collection] = config;
    return game.settings.set("core", this.constructor.CONFIG_SETTING, settings);
  }

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

  /**
   * Delete an existing world-level Compendium Collection.
   * This action may only be performed for world-level packs by a Gamemaster User.
   * @returns {Promise<CompendiumCollection>}
   */
  async deleteCompendium() {
    this.#assertUserCanManage();
    this.apps.forEach(app => app.close());
    const response = await SocketInterface.dispatch("manageCompendium", {
      action: "delete",
      data: this.metadata.name
    });

    return CompendiumCollection.#handleDeleteCompendium(response);
  }

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

  /**
   * Duplicate a compendium pack to the current World.
   * @param {string} label    A new Compendium label
   * @returns {Promise<CompendiumCollection>}
   */
  async duplicateCompendium({label}={}) {
    this.#assertUserCanManage({requireUnlocked: false});
    label = label || this.title;
    const metadata = foundry.utils.mergeObject(this.metadata, {
      name: label.slugify({strict: true}),
      label: label
    }, {inplace: false});
    return this.constructor.createCompendium(metadata, {source: this.collection});
  }

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

  /**
   * Validate that the current user is able to modify content of this Compendium pack
   * @returns {boolean}
   * @private
   */
  #assertUserCanManage({requireUnlocked=true}={}) {
    const config = this.config;
    let err;
    if ( !game.user.isGM ) err = new Error("You do not have permission to modify this compendium pack");
    if ( requireUnlocked && config.locked ) {
      err = new Error("You cannot modify content in this compendium pack because it is locked.");
    }
    if ( err ) {
      ui.notifications.error(err.message);
      throw err;
    }
    return true;
  }

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

  /**
   * Migrate a compendium pack.
   * This operation re-saves all documents within the compendium pack to disk, applying the current data model.
   * If the document type has system data, the latest system data template will also be applied to all documents.
   * @returns {Promise<CompendiumCollection>}
   */
  async migrate() {
    this.#assertUserCanManage();
    ui.notifications.info(`Beginning migration for Compendium pack ${this.collection}, please be patient.`);
    await SocketInterface.dispatch("manageCompendium", {
      type: this.collection,
      action: "migrate",
      data: this.collection,
      options: { broadcast: false }
    });
    ui.notifications.info(`Successfully migrated Compendium pack ${this.collection}.`);
    return this;
  }

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

  /** @inheritdoc */
  async updateAll(transformation, condition=null, options={}) {
    await this.getDocuments();
    options.pack = this.collection;
    return super.updateAll(transformation, condition, options);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onModifyContents(action, documents, result, operation, user) {
    super._onModifyContents(action, documents, result, operation, user);
    Hooks.callAll("updateCompendium", this, documents, operation, user.id);
  }

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

  /**
   * Handle a response from the server where a compendium was created.
   * @param {ManageCompendiumResponse} response  The server response.
   * @returns {CompendiumCollection}
   */
  static #handleCreateCompendium({ result }) {
    game.data.packs.push(result);
    const pack = new this(result);
    game.packs.set(pack.collection, pack);
    pack.apps.push(new Compendium({collection: pack}));
    ui.compendium.render();
    return pack;
  }

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

  /**
   * Handle a response from the server where a compendium was deleted.
   * @param {ManageCompendiumResponse} response  The server response.
   * @returns {CompendiumCollection}
   */
  static #handleDeleteCompendium({ result }) {
    const pack = game.packs.get(result);
    if ( !pack ) throw new Error(`Compendium pack '${result}' did not exist to be deleted.`);
    game.data.packs.findSplice(p => p.id === result);
    game.packs.delete(result);
    ui.compendium.render();
    return pack;
  }

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

  /**
   * @deprecated since v11
   * @ignore
   */
  get private() {
    foundry.utils.logCompatibilityWarning("CompendiumCollection#private is deprecated in favor of the new "
      + "CompendiumCollection#ownership, CompendiumCollection#getUserLevel, CompendiumCollection#visible properties");
    return !this.visible;
  }

  /**
   * @deprecated since v11
   * @ignore
   */
  get isOpen() {
    foundry.utils.logCompatibilityWarning("CompendiumCollection#isOpen is deprecated and will be removed in V13");
    return this.apps.some(app => app._state > Application.RENDER_STATES.NONE);
  }
}

/**
 * A Collection of Folder documents within a Compendium pack.
 */
class CompendiumFolderCollection extends DocumentCollection {
  constructor(pack, data=[]) {
    super(data);
    this.pack = pack;
  }

  /**
   * The CompendiumPack instance which contains this CompendiumFolderCollection
   * @type {CompendiumPack}
   */
  pack;

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

  /** @inheritdoc */
  get documentName() {
    return "Folder";
  }

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

  /** @override */
  render(force, options) {
    this.pack.render(force, options);
  }

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

  /** @inheritdoc */
  async updateAll(transformation, condition=null, options={}) {
    options.pack = this.collection;
    return super.updateAll(transformation, condition, options);
  }
}

class CompendiumPacks extends DirectoryCollectionMixin(Collection) {

  /**
   * Get a Collection of Folders which contain Compendium Packs
   * @returns {Collection<Folder>}
   */
  get folders() {
    return game.folders.reduce((collection, folder) => {
      if ( folder.type === "Compendium" ) {
        collection.set(folder.id, folder);
      }
      return collection;
    }, new foundry.utils.Collection());
  }

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

  /** @override */
  _getVisibleTreeContents() {
    return this.contents.filter(pack => pack.visible);
  }

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

  /** @override */
  static _sortAlphabetical(a, b) {
    if ( a.metadata && b.metadata ) return a.metadata.label.localeCompare(b.metadata.label, game.i18n.lang);
    else return super._sortAlphabetical(a, b);
  }
}

/**
 * The singleton collection of FogExploration documents which exist within the active World.
 * @extends {WorldCollection}
 * @see {@link FogExploration} The FogExploration document
 */
class FogExplorations extends WorldCollection {
  static documentName = "FogExploration";

  /**
   * Activate Socket event listeners to handle for fog resets
   * @param {Socket} socket     The active web socket connection
   * @internal
   */
  static _activateSocketListeners(socket) {
    socket.on("resetFog", ({sceneId}) => {
      if ( sceneId === canvas.id ) {
        canvas.fog._handleReset();
      }
    });
  }
}

/**
 * The singleton collection of Folder documents which exist within the active World.
 * This Collection is accessible within the Game object as game.folders.
 * @extends {WorldCollection}
 *
 * @see {@link Folder} The Folder document
 */
class Folders extends WorldCollection {

  /** @override */
  static documentName = "Folder";

  /**
   * Track which Folders are currently expanded in the UI
   */
  _expanded = {};

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

  /** @override */
  _onModifyContents(action, documents, result, operation, user) {
    if ( operation.render ) {
      const folderTypes = new Set(documents.map(f => f.type));
      for ( const type of folderTypes ) {
        if ( type === "Compendium" ) ui.sidebar.tabs.compendium.render(false);
        else {
          const collection = game.collections.get(type);
          collection.render(false, {renderContext: `${action}${this.documentName}`, renderData: result});
        }
      }
      if ( folderTypes.has("JournalEntry") ) this._refreshJournalEntrySheets();
    }
  }

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

  /**
   * Refresh the display of any active JournalSheet instances where the folder list will change.
   * @private
   */
  _refreshJournalEntrySheets() {
    for ( let app of Object.values(ui.windows) ) {
      if ( !(app instanceof JournalSheet) ) continue;
      app.submit();
    }
  }

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

  /** @override */
  render(force, options={}) {
    console.warn("The Folders collection is not directly rendered");
  }
}

/**
 * The singleton collection of Item documents which exist within the active World.
 * This Collection is accessible within the Game object as game.items.
 * @extends {WorldCollection}
 *
 * @see {@link Item} The Item document
 * @see {@link ItemDirectory} The ItemDirectory sidebar directory
 */
class Items extends WorldCollection {

  /** @override */
  static documentName = "Item";
}

/**
 * The singleton collection of JournalEntry documents which exist within the active World.
 * This Collection is accessible within the Game object as game.journal.
 * @extends {WorldCollection}
 *
 * @see {@link JournalEntry} The JournalEntry document
 * @see {@link JournalDirectory} The JournalDirectory sidebar directory
 */
class Journal extends WorldCollection {

  /** @override */
  static documentName = "JournalEntry";

  /* -------------------------------------------- */
  /*  Interaction Dialogs                         */
  /* -------------------------------------------- */

  /**
   * Display a dialog which prompts the user to show a JournalEntry or JournalEntryPage to other players.
   * @param {JournalEntry|JournalEntryPage} doc  The JournalEntry or JournalEntryPage to show.
   * @returns {Promise<JournalEntry|JournalEntryPage|void>}
   */
  static async showDialog(doc) {
    if ( !((doc instanceof JournalEntry) || (doc instanceof JournalEntryPage)) ) return;
    if ( !doc.isOwner ) return ui.notifications.error("JOURNAL.ShowBadPermissions", {localize: true});
    if ( game.users.size < 2 ) return ui.notifications.warn("JOURNAL.ShowNoPlayers", {localize: true});

    const users = game.users.filter(u => u.id !== game.userId);
    const ownership = Object.entries(CONST.DOCUMENT_OWNERSHIP_LEVELS);
    if ( !doc.isEmbedded ) ownership.shift();
    const levels = [
      {level: CONST.DOCUMENT_META_OWNERSHIP_LEVELS.NOCHANGE, label: "OWNERSHIP.NOCHANGE"},
      ...ownership.map(([name, level]) => ({level, label: `OWNERSHIP.${name}`}))
    ];
    const isImage = (doc instanceof JournalEntryPage) && (doc.type === "image");
    const html = await renderTemplate("templates/journal/dialog-show.html", {users, levels, isImage});

    return Dialog.prompt({
      title: game.i18n.format("JOURNAL.ShowEntry", {name: doc.name}),
      label: game.i18n.localize("JOURNAL.ActionShow"),
      content: html,
      render: html => {
        const form = html.querySelector("form");
        form.elements.allPlayers.addEventListener("change", event => {
          const checked = event.currentTarget.checked;
          form.querySelectorAll('[name="players"]').forEach(i => {
            i.checked = checked;
            i.disabled = checked;
          });
        });
      },
      callback: async html => {
        const form = html.querySelector("form");
        const fd = new FormDataExtended(form).object;
        const users = fd.allPlayers ? game.users.filter(u => !u.isSelf) : fd.players.reduce((arr, id) => {
          const u = game.users.get(id);
          if ( u && !u.isSelf ) arr.push(u);
          return arr;
        }, []);
        if ( !users.length ) return;
        const userIds = users.map(u => u.id);
        if ( fd.ownership > -2 ) {
          const ownership = doc.ownership;
          if ( fd.allPlayers ) ownership.default = fd.ownership;
          for ( const id of userIds ) {
            if ( fd.allPlayers ) {
              if ( (id in ownership) && (ownership[id] <= fd.ownership) ) delete ownership[id];
              continue;
            }
            if ( ownership[id] === CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE ) ownership[id] = fd.ownership;
            ownership[id] = Math.max(ownership[id] ?? -Infinity, fd.ownership);
          }
          await doc.update({ownership}, {diff: false, recursive: false, noHook: true});
        }
        if ( fd.imageOnly ) return this.showImage(doc.src, {
          users: userIds,
          title: doc.name,
          caption: fd.showImageCaption ? doc.image.caption : undefined,
          showTitle: fd.showImageTitle,
          uuid: doc.uuid
        });
        return this.show(doc, {force: true, users: userIds});
      },
      rejectClose: false,
      options: {jQuery: false}
    });
  }

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

  /**
   * Show the JournalEntry or JournalEntryPage to connected players.
   * By default, the document will only be shown to players who have permission to observe it.
   * If the force parameter is passed, the document will be shown to all players regardless of normal permission.
   * @param {JournalEntry|JournalEntryPage} doc  The JournalEntry or JournalEntryPage to show.
   * @param {object} [options]                   Additional options to configure behaviour.
   * @param {boolean} [options.force=false]      Display the entry to all players regardless of normal permissions.
   * @param {string[]} [options.users]           An optional list of user IDs to show the document to. Otherwise it will
   *                                             be shown to all connected clients.
   * @returns {Promise<JournalEntry|JournalEntryPage|void>}  A Promise that resolves back to the shown document once the
   *                                                         request is processed.
   * @throws {Error}                             If the user does not own the document they are trying to show.
   */
  static show(doc, {force=false, users=[]}={}) {
    if ( !((doc instanceof JournalEntry) || (doc instanceof JournalEntryPage)) ) return;
    if ( !doc.isOwner ) throw new Error(game.i18n.localize("JOURNAL.ShowBadPermissions"));
    const strings = Object.fromEntries(["all", "authorized", "selected"].map(k => [k, game.i18n.localize(k)]));
    return new Promise(resolve => {
      game.socket.emit("showEntry", doc.uuid, {force, users}, () => {
        Journal._showEntry(doc.uuid, force);
        ui.notifications.info(game.i18n.format("JOURNAL.ActionShowSuccess", {
          title: doc.name,
          which: users.length ? strings.selected : force ? strings.all : strings.authorized
        }));
        return resolve(doc);
      });
    });
  }

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

  /**
   * Share an image with connected players.
   * @param {string} src                 The image URL to share.
   * @param {ShareImageConfig} [config]  Image sharing configuration.
   */
  static showImage(src, {users=[], ...options}={}) {
    game.socket.emit("shareImage", {image: src, users, ...options});
    const strings = Object.fromEntries(["all", "selected"].map(k => [k, game.i18n.localize(k)]));
    ui.notifications.info(game.i18n.format("JOURNAL.ImageShowSuccess", {
      which: users.length ? strings.selected : strings.all
    }));
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /**
   * Open Socket listeners which transact JournalEntry data
   * @param {Socket} socket       The open websocket
   */
  static _activateSocketListeners(socket) {
    socket.on("showEntry", this._showEntry.bind(this));
    socket.on("shareImage", ImagePopout._handleShareImage);
  }

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

  /**
   * Handle a received request to show a JournalEntry or JournalEntryPage to the current client
   * @param {string} uuid            The UUID of the document to display for other players
   * @param {boolean} [force=false]  Display the document regardless of normal permissions
   * @internal
   */
  static async _showEntry(uuid, force=false) {
    let entry = await fromUuid(uuid);
    const options = {tempOwnership: force, mode: JournalSheet.VIEW_MODES.MULTIPLE, pageIndex: 0};
    const { OBSERVER } = CONST.DOCUMENT_OWNERSHIP_LEVELS;
    if ( entry instanceof JournalEntryPage ) {
      options.mode = JournalSheet.VIEW_MODES.SINGLE;
      options.pageId = entry.id;
      // Set temporary observer permissions for this page.
      if ( entry.getUserLevel(game.user) < OBSERVER ) entry.ownership[game.userId] = OBSERVER;
      entry = entry.parent;
    }
    else if ( entry instanceof JournalEntry ) {
      if ( entry.getUserLevel(game.user) < OBSERVER ) entry.ownership[game.userId] = OBSERVER;
    }
    else return;
    if ( !force && !entry.visible ) return;

    // Show the sheet with the appropriate mode
    entry.sheet.render(true, options);
  }
}

/**
 * The singleton collection of Macro documents which exist within the active World.
 * This Collection is accessible within the Game object as game.macros.
 * @extends {WorldCollection}
 *
 * @see {@link Macro} The Macro document
 * @see {@link MacroDirectory} The MacroDirectory sidebar directory
 */
class Macros extends WorldCollection {

  /** @override */
  static documentName = "Macro";

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

  /** @override */
  get directory() {
    return ui.macros;
  }

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

  /** @inheritdoc */
  fromCompendium(document, options={}) {
    const data = super.fromCompendium(document, options);
    if ( options.clearOwnership ) data.author = game.user.id;
    return data;
  }
}

/**
 * The singleton collection of ChatMessage documents which exist within the active World.
 * This Collection is accessible within the Game object as game.messages.
 * @extends {WorldCollection}
 *
 * @see {@link ChatMessage} The ChatMessage document
 * @see {@link ChatLog} The ChatLog sidebar directory
 */
class Messages extends WorldCollection {

  /** @override */
  static documentName = "ChatMessage";

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

  /**
   * @override
   * @returns {SidebarTab}
   * */
  get directory() {
    return ui.chat;
  }

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

  /** @override */
  render(force=false) {}

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

  /**
   * If requested, dispatch a Chat Bubble UI for the newly created message
   * @param {ChatMessage} message     The ChatMessage document to say
   * @private
   */
  sayBubble(message) {
    const {content, style, speaker} = message;
    if ( speaker.scene === canvas.scene.id ) {
      const token = canvas.tokens.get(speaker.token);
      if ( token ) canvas.hud.bubbles.say(token, content, {
        cssClasses: style === CONST.CHAT_MESSAGE_STYLES.EMOTE ? ["emote"] : []
      });
    }
  }

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

  /**
   * Handle export of the chat log to a text file
   * @private
   */
  export() {
    const log = this.contents.map(m => m.export()).join("\n---------------------------\n");
    let date = new Date().toDateString().replace(/\s/g, "-");
    const filename = `fvtt-log-${date}.txt`;
    saveDataToFile(log, "text/plain", filename);
  }

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

  /**
   * Allow for bulk deletion of all chat messages, confirm first with a yes/no dialog.
   * @see {@link Dialog.confirm}
   */
  async flush() {
    return Dialog.confirm({
      title: game.i18n.localize("CHAT.FlushTitle"),
      content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("CHAT.FlushWarning")}</p>`,
      yes: async () => {
        await this.documentClass.deleteDocuments([], {deleteAll: true});
        const jumpToBottomElement = document.querySelector(".jump-to-bottom");
        jumpToBottomElement.classList.add("hidden");
      },
      options: {
        top: window.innerHeight - 150,
        left: window.innerWidth - 720
      }
    });
  }
}

/**
 * The singleton collection of Playlist documents which exist within the active World.
 * This Collection is accessible within the Game object as game.playlists.
 * @extends {WorldCollection}
 *
 * @see {@link Playlist} The Playlist document
 * @see {@link PlaylistDirectory} The PlaylistDirectory sidebar directory
 */
class Playlists extends WorldCollection {

  /** @override */
  static documentName = "Playlist";

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

  /**
   * Return the subset of Playlist documents which are currently playing
   * @type {Playlist[]}
   */
  get playing() {
    return this.filter(s => s.playing);
  }

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

  /**
   * Perform one-time initialization to begin playback of audio.
   * @returns {Promise<void>}
   */
  async initialize() {
    await game.audio.unlock;
    for ( let playlist of this ) {
      for ( let sound of playlist.sounds ) sound.sync();
    }
    ui.playlists?.render();
  }

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

  /**
   * Handle changes to a Scene to determine whether to trigger changes to Playlist documents.
   * @param {Scene} scene       The Scene document being updated
   * @param {Object} data       The incremental update data
   */
  async _onChangeScene(scene, data) {
    const currentScene = game.scenes.active;
    const p0 = currentScene?.playlist;
    const s0 = currentScene?.playlistSound;
    const p1 = ("playlist" in data) ? game.playlists.get(data.playlist) : scene.playlist;
    const s1 = "playlistSound" in data ? p1?.sounds.get(data.playlistSound) : scene.playlistSound;
    const soundChange = (p0 !== p1) || (s0 !== s1);
    if ( soundChange ) {
      if ( s0 ) await s0.update({playing: false});
      else if ( p0 ) await p0.stopAll();
      if ( s1 ) await s1.update({playing: true});
      else if ( p1 ) await p1.playAll();
    }
  }
}

/**
 * The singleton collection of Scene documents which exist within the active World.
 * This Collection is accessible within the Game object as game.scenes.
 * @extends {WorldCollection}
 *
 * @see {@link Scene} The Scene document
 * @see {@link SceneDirectory} The SceneDirectory sidebar directory
 */
class Scenes extends WorldCollection {

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

  /** @override */
  static documentName = "Scene";

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

  /**
   * Return a reference to the Scene which is currently active
   * @type {Scene}
   */
  get active() {
    return this.find(s => s.active);
  }

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

  /**
   * Return the current Scene target.
   * This is the viewed scene if the canvas is active, otherwise it is the currently active scene.
   * @type {Scene}
   */
  get current() {
    const canvasInitialized = canvas.ready || game.settings.get("core", "noCanvas");
    return canvasInitialized ? this.viewed : this.active;
  }

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

  /**
   * Return a reference to the Scene which is currently viewed
   * @type {Scene}
   */
  get viewed() {
    return this.find(s => s.isView);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Handle preloading the art assets for a Scene
   * @param {string} sceneId    The Scene id to begin loading
   * @param {boolean} push      Trigger other connected clients to also preload Scene resources
   */
  async preload(sceneId, push=false) {
    if ( push ) return game.socket.emit("preloadScene", sceneId, () => this.preload(sceneId));
    let scene = this.get(sceneId);
    const promises = [];

    // Preload sounds
    if ( scene.playlistSound?.path ) promises.push(foundry.audio.AudioHelper.preloadSound(scene.playlistSound.path));
    else if ( scene.playlist?.playbackOrder.length ) {
      const first = scene.playlist.sounds.get(scene.playlist.playbackOrder[0]);
      if ( first ) promises.push(foundry.audio.AudioHelper.preloadSound(first.path));
    }

    // Preload textures without expiring current ones
    promises.push(TextureLoader.loadSceneTextures(scene, {expireCache: false}));
    return Promise.all(promises);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @override */
  static _activateSocketListeners(socket) {
    socket.on("preloadScene", sceneId => this.instance.preload(sceneId));
    socket.on("pullToScene", this._pullToScene);
  }

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

  /**
   * Handle requests pulling the current User to a specific Scene
   * @param {string} sceneId
   * @private
   */
  static _pullToScene(sceneId) {
    const scene = game.scenes.get(sceneId);
    if ( scene ) scene.view();
  }

  /* -------------------------------------------- */
  /*  Importing and Exporting                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  fromCompendium(document, { clearState=true, clearSort=true, ...options }={}) {
    const data = super.fromCompendium(document, { clearSort, ...options });
    if ( clearState ) delete data.active;
    if ( clearSort ) {
      data.navigation = false;
      delete data.navOrder;
    }
    return data;
  }
}

/**
 * The Collection of Setting documents which exist within the active World.
 * This collection is accessible as game.settings.storage.get("world")
 * @extends {WorldCollection}
 *
 * @see {@link Setting} The Setting document
 */
class WorldSettings extends WorldCollection {

  /** @override */
  static documentName = "Setting";

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

  /** @override */
  get directory() {
    return null;
  }

  /* -------------------------------------------- */
  /* World Settings Methods                       */
  /* -------------------------------------------- */

  /**
   * Return the Setting document with the given key.
   * @param {string} key        The setting key
   * @returns {Setting}         The Setting
   */
  getSetting(key) {
    return this.find(s => s.key === key);
  }

  /**
   * Return the serialized value of the world setting as a string
   * @param {string} key    The setting key
   * @returns {string|null}  The serialized setting string
   */
  getItem(key) {
    return this.getSetting(key)?.value ?? null;
  }
}

/**
 * The singleton collection of RollTable documents which exist within the active World.
 * This Collection is accessible within the Game object as game.tables.
 * @extends {WorldCollection}
 *
 * @see {@link RollTable} The RollTable document
 * @see {@link RollTableDirectory} The RollTableDirectory sidebar directory
 */
class RollTables extends WorldCollection {

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

  /** @override */
  static documentName = "RollTable";

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

  /** @override */
  get directory() {
    return ui.tables;
  }

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

  /**
   * Register world settings related to RollTable documents
   */
  static registerSettings() {

    // Show Player Cursors
    game.settings.register("core", "animateRollTable", {
      name: "TABLE.AnimateSetting",
      hint: "TABLE.AnimateSettingHint",
      scope: "world",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: true})
    });
  }
}

/**
 * The singleton collection of User documents which exist within the active World.
 * This Collection is accessible within the Game object as game.users.
 * @extends {WorldCollection}
 *
 * @see {@link User} The User document
 */
class Users extends WorldCollection {
  constructor(...args) {
    super(...args);

    /**
     * The User document of the currently connected user
     * @type {User|null}
     */
    this.current = this.current || null;
  }

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

  /**
   * Initialize the Map object and all its contained documents
   * @private
   * @override
   */
  _initialize() {
    super._initialize();

    // Flag the current user
    this.current = this.get(game.data.userId) || null;
    if ( this.current ) this.current.active = true;

    // Set initial user activity state
    for ( let activeId of game.data.activeUsers || [] ) {
      this.get(activeId).active = true;
    }
  }

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

  /** @override */
  static documentName = "User";

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

  /**
   * Get the users with player roles
   * @returns {User[]}
   */
  get players() {
    return this.filter(u => !u.isGM && u.hasRole("PLAYER"));
  }

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

  /**
   * Get one User who is an active Gamemaster (non-assistant if possible), or null if no active GM is available.
   * This can be useful for workflows which occur on all clients, but where only one user should take action.
   * @type {User|null}
   */
  get activeGM() {
    const activeGMs = game.users.filter(u => u.active && u.isGM);
    activeGMs.sort((a, b) => (b.role - a.role) || a.id.compare(b.id)); // Alphanumeric sort IDs without using localeCompare
    return activeGMs[0] || null;
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  static _activateSocketListeners(socket) {
    socket.on("userActivity", this._handleUserActivity);
  }

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

  /**
   * Handle receipt of activity data from another User connected to the Game session
   * @param {string} userId               The User id who generated the activity data
   * @param {ActivityData} activityData   The object of activity data
   * @private
   */
  static _handleUserActivity(userId, activityData={}) {
    const user = game.users.get(userId);
    if ( !user || user.isSelf ) return;

    // Update User active state
    const active = "active" in activityData ? activityData.active : true;
    if ( user.active !== active ) {
      user.active = active;
      game.users.render();
      ui.nav.render();
      Hooks.callAll("userConnected", user, active);
    }

    // Everything below here requires the game to be ready
    if ( !game.ready ) return;

    // Set viewed scene
    const sceneChange = ("sceneId" in activityData) && (activityData.sceneId !== user.viewedScene);
    if ( sceneChange ) {
      user.viewedScene = activityData.sceneId;
      ui.nav.render();
    }

    if ( "av" in activityData ) {
      game.webrtc.settings.handleUserActivity(userId, activityData.av);
    }

    // Everything below requires an active canvas
    if ( !canvas.ready ) return;

    // User control deactivation
    if ( (active === false) || (user.viewedScene !== canvas.id) ) {
      canvas.controls.updateCursor(user, null);
      canvas.controls.updateRuler(user, null);
      user.updateTokenTargets([]);
      return;
    }

    // Cursor position
    if ( "cursor" in activityData ) {
      canvas.controls.updateCursor(user, activityData.cursor);
    }

    // Was it a ping?
    if ( "ping" in activityData ) {
      canvas.controls.handlePing(user, activityData.cursor, activityData.ping);
    }

    // Ruler measurement
    if ( "ruler" in activityData ) {
      canvas.controls.updateRuler(user, activityData.ruler);
    }

    // Token targets
    if ( "targets" in activityData ) {
      user.updateTokenTargets(activityData.targets);
    }
  }
}

/**
 * @typedef {EffectDurationData} ActiveEffectDuration
 * @property {string} type            The duration type, either "seconds", "turns", or "none"
 * @property {number|null} duration   The total effect duration, in seconds of world time or as a decimal
 *                                    number with the format {rounds}.{turns}
 * @property {number|null} remaining  The remaining effect duration, in seconds of world time or as a decimal
 *                                    number with the format {rounds}.{turns}
 * @property {string} label           A formatted string label that represents the remaining duration
 * @property {number} [_worldTime]    An internal flag used determine when to recompute seconds-based duration
 * @property {number} [_combatTime]   An internal flag used determine when to recompute turns-based duration
 */

/**
 * The client-side ActiveEffect document which extends the common BaseActiveEffect model.
 * Each ActiveEffect belongs to the effects collection of its parent Document.
 * Each ActiveEffect contains a ActiveEffectData object which provides its source data.
 *
 * @extends foundry.documents.BaseActiveEffect
 * @mixes ClientDocumentMixin
 *
 * @see {@link Actor} The Actor document which contains ActiveEffect embedded documents
 * @see {@link Item}  The Item document which contains ActiveEffect embedded documents
 *
 * @property {ActiveEffectDuration} duration        Expanded effect duration data.
 */
class ActiveEffect extends ClientDocumentMixin(foundry.documents.BaseActiveEffect) {

  /**
   * Create an ActiveEffect instance from some status effect ID.
   * Delegates to {@link ActiveEffect._fromStatusEffect} to create the ActiveEffect instance
   * after creating the ActiveEffect data from the status effect data if `CONFIG.statusEffects`.
   * @param {string} statusId                             The status effect ID.
   * @param {DocumentConstructionContext} [options]       Additional options to pass to the ActiveEffect constructor.
   * @returns {Promise<ActiveEffect>}                     The created ActiveEffect instance.
   *
   * @throws {Error} An error if there is no status effect in `CONFIG.statusEffects` with the given status ID and if
   * the status has implicit statuses but doesn't have a static _id.
   */
  static async fromStatusEffect(statusId, options={}) {
    const status = CONFIG.statusEffects.find(e => e.id === statusId);
    if ( !status ) throw new Error(`Invalid status ID "${statusId}" provided to ActiveEffect#fromStatusEffect`);
    /** @deprecated since v12 */
    for ( const [oldKey, newKey] of Object.entries({label: "name", icon: "img"}) ) {
      if ( !(newKey in status) && (oldKey in status) ) {
        const msg = `StatusEffectConfig#${oldKey} has been deprecated in favor of StatusEffectConfig#${newKey}`;
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
      }
    }
    const {id, label, icon, hud, ...effectData} = foundry.utils.deepClone(status);
    effectData.name = game.i18n.localize(effectData.name ?? /** @deprecated since v12 */ label);
    effectData.img ??= /** @deprecated since v12 */ icon;
    effectData.statuses = Array.from(new Set([id, ...effectData.statuses ?? []]));
    if ( (effectData.statuses.length > 1) && !status._id ) {
      throw new Error("Status effects with implicit statuses must have a static _id");
    }
    return ActiveEffect.implementation._fromStatusEffect(statusId, effectData, options);
  }

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

  /**
   * Create an ActiveEffect instance from status effect data.
   * Called by {@link ActiveEffect.fromStatusEffect}.
   * @param {string} statusId                          The status effect ID.
   * @param {ActiveEffectData} effectData              The status effect data.
   * @param {DocumentConstructionContext} [options]    Additional options to pass to the ActiveEffect constructor.
   * @returns {Promise<ActiveEffect>}                  The created ActiveEffect instance.
   * @protected
   */
  static async _fromStatusEffect(statusId, effectData, options) {
    return new this(effectData, options);
  }

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

  /**
   * Is there some system logic that makes this active effect ineligible for application?
   * @type {boolean}
   */
  get isSuppressed() {
    return false;
  }

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

  /**
   * Retrieve the Document that this ActiveEffect targets for modification.
   * @type {Document|null}
   */
  get target() {
    if ( this.parent instanceof Actor ) return this.parent;
    if ( CONFIG.ActiveEffect.legacyTransferral ) return this.transfer ? null : this.parent;
    return this.transfer ? (this.parent.parent ?? null) : this.parent;
  }

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

  /**
   * Whether the Active Effect currently applying its changes to the target.
   * @type {boolean}
   */
  get active() {
    return !this.disabled && !this.isSuppressed;
  }

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

  /**
   * Does this Active Effect currently modify an Actor?
   * @type {boolean}
   */
  get modifiesActor() {
    if ( !this.active ) return false;
    if ( CONFIG.ActiveEffect.legacyTransferral ) return this.parent instanceof Actor;
    return this.target instanceof Actor;
  }

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

  /** @inheritdoc */
  prepareBaseData() {
    /** @deprecated since v11 */
    const statusId = this.flags.core?.statusId;
    if ( (typeof statusId === "string") && (statusId !== "") ) this.statuses.add(statusId);
  }

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

  /** @inheritdoc */
  prepareDerivedData() {
    this.updateDuration();
  }

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

  /**
   * Update derived Active Effect duration data.
   * Configure the remaining and label properties to be getters which lazily recompute only when necessary.
   * @returns {ActiveEffectDuration}
   */
  updateDuration() {
    const {remaining, label, ...durationData} = this._prepareDuration();
    Object.assign(this.duration, durationData);
    const getOrUpdate = (attr, value) => this._requiresDurationUpdate() ? this.updateDuration()[attr] : value;
    Object.defineProperties(this.duration, {
      remaining: {
        get: getOrUpdate.bind(this, "remaining", remaining),
        configurable: true
      },
      label: {
        get: getOrUpdate.bind(this, "label", label),
        configurable: true
      }
    });
    return this.duration;
  }

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

  /**
   * Determine whether the ActiveEffect requires a duration update.
   * True if the worldTime has changed for an effect whose duration is tracked in seconds.
   * True if the combat turn has changed for an effect tracked in turns where the effect target is a combatant.
   * @returns {boolean}
   * @protected
   */
  _requiresDurationUpdate() {
    const {_worldTime, _combatTime, type} = this.duration;
    if ( type === "seconds" ) return game.time.worldTime !== _worldTime;
    if ( (type === "turns") && game.combat ) {
      const ct = this._getCombatTime(game.combat.round, game.combat.turn);
      return (ct !== _combatTime) && !!this.target?.inCombat;
    }
    return false;
  }

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

  /**
   * Compute derived data related to active effect duration.
   * @returns {{
   *   type: string,
   *   duration: number|null,
   *   remaining: number|null,
   *   label: string,
   *   [_worldTime]: number,
   *   [_combatTime]: number}
   * }
   * @internal
   */
  _prepareDuration() {
    const d = this.duration;

    // Time-based duration
    if ( Number.isNumeric(d.seconds) ) {
      const wt = game.time.worldTime;
      const start = (d.startTime || wt);
      const elapsed = wt - start;
      const remaining = d.seconds - elapsed;
      return {
        type: "seconds",
        duration: d.seconds,
        remaining: remaining,
        label: `${remaining} ${game.i18n.localize("Seconds")}`,
        _worldTime: wt
      };
    }

    // Turn-based duration
    else if ( d.rounds || d.turns ) {
      const cbt = game.combat;
      if ( !cbt ) return {
        type: "turns",
        _combatTime: undefined
      };

      // Determine the current combat duration
      const c = {round: cbt.round ?? 0, turn: cbt.turn ?? 0, nTurns: cbt.turns.length || 1};
      const current = this._getCombatTime(c.round, c.turn);
      const duration = this._getCombatTime(d.rounds, d.turns);
      const start = this._getCombatTime(d.startRound, d.startTurn, c.nTurns);

      // If the effect has not started yet display the full duration
      if ( current <= start ) return {
        type: "turns",
        duration: duration,
        remaining: duration,
        label: this._getDurationLabel(d.rounds, d.turns),
        _combatTime: current
      };

      // Some number of remaining rounds and turns (possibly zero)
      const remaining = Math.max(((start + duration) - current).toNearest(0.01), 0);
      const remainingRounds = Math.floor(remaining);
      let remainingTurns = 0;
      if ( remaining > 0 ) {
        let nt = c.turn - d.startTurn;
        while ( nt < 0 ) nt += c.nTurns;
        remainingTurns = nt > 0 ? c.nTurns - nt : 0;
      }
      return {
        type: "turns",
        duration: duration,
        remaining: remaining,
        label: this._getDurationLabel(remainingRounds, remainingTurns),
        _combatTime: current
      };
    }

    // No duration
    return {
      type: "none",
      duration: null,
      remaining: null,
      label: game.i18n.localize("None")
    };
  }

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

  /**
   * Format a round+turn combination as a decimal
   * @param {number} round    The round number
   * @param {number} turn     The turn number
   * @param {number} [nTurns] The maximum number of turns in the encounter
   * @returns {number}        The decimal representation
   * @private
   */
  _getCombatTime(round, turn, nTurns) {
    if ( nTurns !== undefined ) turn = Math.min(turn, nTurns);
    round = Math.max(round, 0);
    turn = Math.max(turn, 0);
    return (round || 0) + ((turn || 0) / 100);
  }

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

  /**
   * Format a number of rounds and turns into a human-readable duration label
   * @param {number} rounds   The number of rounds
   * @param {number} turns    The number of turns
   * @returns {string}        The formatted label
   * @private
   */
  _getDurationLabel(rounds, turns) {
    const parts = [];
    if ( rounds > 0 ) parts.push(`${rounds} ${game.i18n.localize(rounds === 1 ? "COMBAT.Round": "COMBAT.Rounds")}`);
    if ( turns > 0 ) parts.push(`${turns} ${game.i18n.localize(turns === 1 ? "COMBAT.Turn": "COMBAT.Turns")}`);
    if (( rounds + turns ) === 0 ) parts.push(game.i18n.localize("None"));
    return parts.filterJoin(", ");
  }

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

  /**
   * Describe whether the ActiveEffect has a temporary duration based on combat turns or rounds.
   * @type {boolean}
   */
  get isTemporary() {
    const duration = this.duration.seconds ?? (this.duration.rounds || this.duration.turns) ?? 0;
    return (duration > 0) || (this.statuses.size > 0);
  }

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

  /**
   * The source name of the Active Effect. The source is retrieved synchronously.
   * Therefore "Unknown" (localized) is returned if the origin points to a document inside a compendium.
   * Returns "None" (localized) if it has no origin, and "Unknown" (localized) if the origin cannot be resolved.
   * @type {string}
   */
  get sourceName() {
    if ( !this.origin ) return game.i18n.localize("None");
    let name;
    try {
      name = fromUuidSync(this.origin)?.name;
    } catch(e) {}
    return name || game.i18n.localize("Unknown");
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Apply EffectChangeData to a field within a DataModel.
   * @param {DataModel} model          The model instance.
   * @param {EffectChangeData} change  The change to apply.
   * @param {DataField} [field]        The field. If not supplied, it will be retrieved from the supplied model.
   * @returns {*}                      The updated value.
   */
  static applyField(model, change, field) {
    field ??= model.schema.getField(change.key);
    const current = foundry.utils.getProperty(model, change.key);
    const update = field.applyChange(current, model, change);
    foundry.utils.setProperty(model, change.key, update);
    return update;
  }

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

  /**
   * Apply this ActiveEffect to a provided Actor.
   * TODO: This method is poorly conceived. Its functionality is static, applying a provided change to an Actor
   * TODO: When we revisit this in Active Effects V2 this should become an Actor method, or a static method
   * @param {Actor} actor                   The Actor to whom this effect should be applied
   * @param {EffectChangeData} change       The change data being applied
   * @returns {Record<string, *>}           An object of property paths and their updated values.
   */

  apply(actor, change) {
    let field;
    const changes = {};
    if ( change.key.startsWith("system.") ) {
      if ( actor.system instanceof foundry.abstract.DataModel ) {
        field = actor.system.schema.getField(change.key.slice(7));
      }
    } else field = actor.schema.getField(change.key);
    if ( field ) changes[change.key] = this.constructor.applyField(actor, change, field);
    else this._applyLegacy(actor, change, changes);
    return changes;
  }

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

  /**
   * Apply this ActiveEffect to a provided Actor using a heuristic to infer the value types based on the current value
   * and/or the default value in the template.json.
   * @param {Actor} actor                The Actor to whom this effect should be applied.
   * @param {EffectChangeData} change    The change data being applied.
   * @param {Record<string, *>} changes  The aggregate update paths and their updated values.
   * @protected
   */
  _applyLegacy(actor, change, changes) {
    // Determine the data type of the target field
    const current = foundry.utils.getProperty(actor, change.key) ?? null;
    let target = current;
    if ( current === null ) {
      const model = game.model.Actor[actor.type] || {};
      target = foundry.utils.getProperty(model, change.key) ?? null;
    }
    let targetType = foundry.utils.getType(target);

    // Cast the effect change value to the correct type
    let delta;
    try {
      if ( targetType === "Array" ) {
        const innerType = target.length ? foundry.utils.getType(target[0]) : "string";
        delta = this._castArray(change.value, innerType);
      }
      else delta = this._castDelta(change.value, targetType);
    } catch(err) {
      console.warn(`Actor [${actor.id}] | Unable to parse active effect change for ${change.key}: "${change.value}"`);
      return;
    }

    // Apply the change depending on the application mode
    const modes = CONST.ACTIVE_EFFECT_MODES;
    switch ( change.mode ) {
      case modes.ADD:
        this._applyAdd(actor, change, current, delta, changes);
        break;
      case modes.MULTIPLY:
        this._applyMultiply(actor, change, current, delta, changes);
        break;
      case modes.OVERRIDE:
        this._applyOverride(actor, change, current, delta, changes);
        break;
      case modes.UPGRADE:
      case modes.DOWNGRADE:
        this._applyUpgrade(actor, change, current, delta, changes);
        break;
      default:
        this._applyCustom(actor, change, current, delta, changes);
        break;
    }

    // Apply all changes to the Actor data
    foundry.utils.mergeObject(actor, changes);
  }

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

  /**
   * Cast a raw EffectChangeData change string to the desired data type.
   * @param {string} raw      The raw string value
   * @param {string} type     The target data type that the raw value should be cast to match
   * @returns {*}             The parsed delta cast to the target data type
   * @private
   */
  _castDelta(raw, type) {
    let delta;
    switch ( type ) {
      case "boolean":
        delta = Boolean(this._parseOrString(raw));
        break;
      case "number":
        delta = Number.fromString(raw);
        if ( Number.isNaN(delta) ) delta = 0;
        break;
      case "string":
        delta = String(raw);
        break;
      default:
        delta = this._parseOrString(raw);
    }
    return delta;
  }

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

  /**
   * Cast a raw EffectChangeData change string to an Array of an inner type.
   * @param {string} raw      The raw string value
   * @param {string} type     The target data type of inner array elements
   * @returns {Array<*>}      The parsed delta cast as a typed array
   * @private
   */
  _castArray(raw, type) {
    let delta;
    try {
      delta = this._parseOrString(raw);
      delta = delta instanceof Array ? delta : [delta];
    } catch(e) {
      delta = [raw];
    }
    return delta.map(d => this._castDelta(d, type));
  }

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

  /**
   * Parse serialized JSON, or retain the raw string.
   * @param {string} raw      A raw serialized string
   * @returns {*}             The parsed value, or the original value if parsing failed
   * @private
   */
  _parseOrString(raw) {
    try {
      return JSON.parse(raw);
    } catch(err) {
      return raw;
    }
  }

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

  /**
   * Apply an ActiveEffect that uses an ADD application mode.
   * The way that effects are added depends on the data type of the current value.
   *
   * If the current value is null, the change value is assigned directly.
   * If the current type is a string, the change value is concatenated.
   * If the current type is a number, the change value is cast to numeric and added.
   * If the current type is an array, the change value is appended to the existing array if it matches in type.
   *
   * @param {Actor} actor                   The Actor to whom this effect should be applied
   * @param {EffectChangeData} change       The change data being applied
   * @param {*} current                     The current value being modified
   * @param {*} delta                       The parsed value of the change object
   * @param {object} changes                An object which accumulates changes to be applied
   * @private
   */
  _applyAdd(actor, change, current, delta, changes) {
    let update;
    const ct = foundry.utils.getType(current);
    switch ( ct ) {
      case "boolean":
        update = current || delta;
        break;
      case "null":
        update = delta;
        break;
      case "Array":
        update = current.concat(delta);
        break;
      default:
        update = current + delta;
        break;
    }
    changes[change.key] = update;
  }

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

  /**
   * Apply an ActiveEffect that uses a MULTIPLY application mode.
   * Changes which MULTIPLY must be numeric to allow for multiplication.
   * @param {Actor} actor                   The Actor to whom this effect should be applied
   * @param {EffectChangeData} change       The change data being applied
   * @param {*} current                     The current value being modified
   * @param {*} delta                       The parsed value of the change object
   * @param {object} changes                An object which accumulates changes to be applied
   * @private
   */
  _applyMultiply(actor, change, current, delta, changes) {
    let update;
    const ct = foundry.utils.getType(current);
    switch ( ct ) {
      case "boolean":
        update = current && delta;
        break;
      case "number":
        update = current * delta;
        break;
    }
    changes[change.key] = update;
  }

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

  /**
   * Apply an ActiveEffect that uses an OVERRIDE application mode.
   * Numeric data is overridden by numbers, while other data types are overridden by any value
   * @param {Actor} actor                   The Actor to whom this effect should be applied
   * @param {EffectChangeData} change       The change data being applied
   * @param {*} current                     The current value being modified
   * @param {*} delta                       The parsed value of the change object
   * @param {object} changes                An object which accumulates changes to be applied
   * @private
   */
  _applyOverride(actor, change, current, delta, changes) {
    return changes[change.key] = delta;
  }

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

  /**
   * Apply an ActiveEffect that uses an UPGRADE, or DOWNGRADE application mode.
   * Changes which UPGRADE or DOWNGRADE must be numeric to allow for comparison.
   * @param {Actor} actor                   The Actor to whom this effect should be applied
   * @param {EffectChangeData} change       The change data being applied
   * @param {*} current                     The current value being modified
   * @param {*} delta                       The parsed value of the change object
   * @param {object} changes                An object which accumulates changes to be applied
   * @private
   */
  _applyUpgrade(actor, change, current, delta, changes) {
    let update;
    const ct = foundry.utils.getType(current);
    switch ( ct ) {
      case "boolean":
      case "number":
        if ( (change.mode === CONST.ACTIVE_EFFECT_MODES.UPGRADE) && (delta > current) ) update = delta;
        else if ( (change.mode === CONST.ACTIVE_EFFECT_MODES.DOWNGRADE) && (delta < current) ) update = delta;
        break;
    }
    changes[change.key] = update;
  }

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

  /**
   * Apply an ActiveEffect that uses a CUSTOM application mode.
   * @param {Actor} actor                   The Actor to whom this effect should be applied
   * @param {EffectChangeData} change       The change data being applied
   * @param {*} current                     The current value being modified
   * @param {*} delta                       The parsed value of the change object
   * @param {object} changes                An object which accumulates changes to be applied
   * @private
   */
  _applyCustom(actor, change, current, delta, changes) {
    const preHook = foundry.utils.getProperty(actor, change.key);
    Hooks.call("applyActiveEffect", actor, change, current, delta, changes);
    const postHook = foundry.utils.getProperty(actor, change.key);
    if ( postHook !== preHook ) changes[change.key] = postHook;
  }

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

  /**
   * Retrieve the initial duration configuration.
   * @returns {{duration: {startTime: number, [startRound]: number, [startTurn]: number}}}
   */
  static getInitialDuration() {
    const data = {duration: {startTime: game.time.worldTime}};
    if ( game.combat ) {
      data.duration.startRound = game.combat.round;
      data.duration.startTurn = game.combat.turn ?? 0;
    }
    return data;
  }

  /* -------------------------------------------- */
  /*  Flag Operations                             */
  /* -------------------------------------------- */

  /** @inheritdoc */
  getFlag(scope, key) {
    if ( (scope === "core") && (key === "statusId") ) {
      foundry.utils.logCompatibilityWarning("You are setting flags.core.statusId on an Active Effect. This flag is"
        + " deprecated in favor of the statuses set.", {since: 11, until: 13});
    }
    return super.getFlag(scope, key);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;
    if ( foundry.utils.hasProperty(data, "flags.core.statusId") ) {
      foundry.utils.logCompatibilityWarning("You are setting flags.core.statusId on an Active Effect. This flag is"
        + " deprecated in favor of the statuses set.", {since: 11, until: 13});
    }

    // Set initial duration data for Actor-owned effects
    if ( this.parent instanceof Actor ) {
      const updates = this.constructor.getInitialDuration();
      for ( const k of Object.keys(updates.duration) ) {
        if ( Number.isNumeric(data.duration?.[k]) ) delete updates.duration[k]; // Prefer user-defined duration data
      }
      updates.transfer = false;
      this.updateSource(updates);
    }
  }

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

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( this.modifiesActor && (options.animate !== false) ) this._displayScrollingStatus(true);
  }

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

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( foundry.utils.hasProperty(changed, "flags.core.statusId")
      || foundry.utils.hasProperty(changed, "flags.core.-=statusId") ) {
      foundry.utils.logCompatibilityWarning("You are setting flags.core.statusId on an Active Effect. This flag is"
        + " deprecated in favor of the statuses set.", {since: 11, until: 13});
    }
    if ( ("statuses" in changed) && (this._source.flags.core?.statusId !== undefined) ) {
      foundry.utils.setProperty(changed, "flags.core.-=statusId", null);
    }
    return super._preUpdate(changed, options, user);
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( !(this.target instanceof Actor) ) return;
    const activeChanged = "disabled" in changed;
    if ( activeChanged && (options.animate !== false) ) this._displayScrollingStatus(this.active);
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( this.modifiesActor && (options.animate !== false) ) this._displayScrollingStatus(false);
  }

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

  /**
   * Display changes to active effects as scrolling Token status text.
   * @param {boolean} enabled     Is the active effect currently enabled?
   * @protected
   */
  _displayScrollingStatus(enabled) {
    if ( !(this.statuses.size || this.changes.length) ) return;
    const actor = this.target;
    const tokens = actor.getActiveTokens(true);
    const text = `${enabled ? "+" : "-"}(${this.name})`;
    for ( let t of tokens ) {
      if ( !t.visible || t.document.isSecret ) continue;
      canvas.interface.createScrollingText(t.center, text, {
        anchor: CONST.TEXT_ANCHOR_POINTS.CENTER,
        direction: enabled ? CONST.TEXT_ANCHOR_POINTS.TOP : CONST.TEXT_ANCHOR_POINTS.BOTTOM,
        distance: (2 * t.h),
        fontSize: 28,
        stroke: 0x000000,
        strokeThickness: 4,
        jitter: 0.25
      });
    }
  }

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

  /**
   * Get the name of the source of the Active Effect
   * @type {string}
   * @deprecated since v11
   * @ignore
   */
  async _getSourceName() {
    const warning = "You are accessing ActiveEffect._getSourceName which is deprecated.";
    foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
    if ( !this.origin ) return game.i18n.localize("None");
    const source = await fromUuid(this.origin);
    return source?.name ?? game.i18n.localize("Unknown");
  }
}

/**
 * The client-side ActorDelta embedded document which extends the common BaseActorDelta document model.
 * @extends foundry.documents.BaseActorDelta
 * @mixes ClientDocumentMixin
 * @see {@link TokenDocument}  The TokenDocument document type which contains ActorDelta embedded documents.
 */
class ActorDelta extends ClientDocumentMixin(foundry.documents.BaseActorDelta) {
  /** @inheritdoc */
  _configure(options={}) {
    super._configure(options);
    this._createSyntheticActor();
  }

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

  /** @inheritdoc */
  _initialize({sceneReset=false, ...options}={}) {
    // Do not initialize the ActorDelta as part of a Scene reset.
    if ( sceneReset ) return;
    super._initialize(options);
    if ( !this.parent.isLinked && (this.syntheticActor?.id !== this.parent.actorId) ) {
      this._createSyntheticActor({ reinitializeCollections: true });
    }
  }

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

  /**
   * Pass-through the type from the synthetic Actor, if it exists.
   * @type {string}
   */
  get type() {
    return this.syntheticActor?.type ?? this._type ?? this._source.type;
  }

  set type(type) {
    this._type = type;
  }

  _type;

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Apply this ActorDelta to the base Actor and return a synthetic Actor.
   * @param {object} [context]  Context to supply to synthetic Actor instantiation.
   * @returns {Actor|null}
   */
  apply(context={}) {
    return this.constructor.applyDelta(this, this.parent.baseActor, context);
  }

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

  /** @override */
  prepareEmbeddedDocuments() {
    // The synthetic actor prepares its items in the appropriate context of an actor. The actor delta does not need to
    // prepare its items, and would do so in the incorrect context.
  }

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

  /** @inheritdoc */
  updateSource(changes={}, options={}) {
    // If there is no baseActor, there is no synthetic actor either, so we do nothing.
    if ( !this.syntheticActor || !this.parent.baseActor ) return {};

    // Perform an update on the synthetic Actor first to validate the changes.
    let actorChanges = foundry.utils.deepClone(changes);
    delete actorChanges._id;
    actorChanges.type ??= this.syntheticActor.type;
    actorChanges.name ??= this.syntheticActor.name;

    // In the non-recursive case we must apply the changes as actor delta changes first in order to get an appropriate
    // actor update, otherwise applying an actor delta update non-recursively to an actor will truncate most of its
    // data.
    if ( options.recursive === false ) {
      const tmpDelta = new ActorDelta.implementation(actorChanges, { parent: this.parent });
      const updatedActor = this.constructor.applyDelta(tmpDelta, this.parent.baseActor);
      if ( updatedActor ) actorChanges = updatedActor.toObject();
    }

    this.syntheticActor.updateSource(actorChanges, { ...options });
    const diff = super.updateSource(changes, options);

    // If this was an embedded update, re-apply the delta to make sure embedded collections are merged correctly.
    const embeddedUpdate = Object.keys(this.constructor.hierarchy).some(k => k in changes);
    const deletionUpdate = Object.keys(foundry.utils.flattenObject(changes)).some(k => k.includes("-="));
    if ( !this.parent.isLinked && (embeddedUpdate || deletionUpdate) ) this.updateSyntheticActor();
    return diff;
  }

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

  /** @inheritdoc */
  reset() {
    super.reset();
    // Propagate reset calls on the ActorDelta to the synthetic Actor.
    if ( !this.parent.isLinked ) this.syntheticActor?.reset();
  }

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

  /**
   * Generate a synthetic Actor instance when constructed, or when the represented Actor, or actorLink status changes.
   * @param {object} [options]
   * @param {boolean} [options.reinitializeCollections]  Whether to fully re-initialize this ActorDelta's collections in
   *                                                     order to re-retrieve embedded Documents from the synthetic
   *                                                     Actor.
   * @internal
   */
  _createSyntheticActor({ reinitializeCollections=false }={}) {
    Object.defineProperty(this, "syntheticActor", {value: this.apply({strict: false}), configurable: true});
    if ( reinitializeCollections ) {
      for ( const collection of Object.values(this.collections) ) collection.initialize({ full: true });
    }
  }

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

  /**
   * Update the synthetic Actor instance with changes from the delta or the base Actor.
   */
  updateSyntheticActor() {
    if ( this.parent.isLinked ) return;
    const updatedActor = this.apply();
    if ( updatedActor ) this.syntheticActor.updateSource(updatedActor.toObject(), {diff: false, recursive: false});
  }

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

  /**
   * Restore this delta to empty, inheriting all its properties from the base actor.
   * @returns {Promise<Actor>}  The restored synthetic Actor.
   */
  async restore() {
    if ( !this.parent.isLinked ) await Promise.all(Object.values(this.syntheticActor.apps).map(app => app.close()));
    await this.delete({diff: false, recursive: false, restoreDelta: true});
    return this.parent.actor;
  }

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

  /**
   * Ensure that the embedded collection delta is managing any entries that have had their descendants updated.
   * @param {Document} doc  The parent whose immediate children have been modified.
   * @internal
   */
  _handleDeltaCollectionUpdates(doc) {
    // Recurse up to an immediate child of the ActorDelta.
    if ( !doc ) return;
    if ( doc.parent !== this ) return this._handleDeltaCollectionUpdates(doc.parent);
    const collection = this.getEmbeddedCollection(doc.parentCollection);
    if ( !collection.manages(doc.id) ) collection.set(doc.id, doc);
  }

  /* -------------------------------------------- */
  /*  Database Operations                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preDelete(options, user) {
    if ( this.parent.isLinked ) return super._preDelete(options, user);
    // Emulate a synthetic actor update.
    const data = this.parent.baseActor.toObject();
    let allowed = await this.syntheticActor._preUpdate(data, options, user) ?? true;
    allowed &&= (options.noHook || Hooks.call("preUpdateActor", this.syntheticActor, data, options, user.id));
    if ( allowed === false ) {
      console.debug(`${vtt} | Actor update prevented during pre-update`);
      return false;
    }
    return super._preDelete(options, user);
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( this.parent.isLinked ) return;
    this.syntheticActor._onUpdate(changed, options, userId);
    Hooks.callAll("updateActor", this.syntheticActor, changed, options, userId);
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( !this.parent.baseActor ) return;
    // Create a new, ephemeral ActorDelta Document in the parent Token and emulate synthetic actor update.
    this.parent.updateSource({ delta: { _id: this.parent.id } });
    this.parent.delta._onUpdate(this.parent.baseActor.toObject(), options, userId);
  }

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

  /** @inheritDoc */
  _dispatchDescendantDocumentEvents(event, collection, args, _parent) {
    super._dispatchDescendantDocumentEvents(event, collection, args, _parent);
    if ( !_parent ) {
      // Emulate descendant events on the synthetic actor.
      const fn = this.syntheticActor[`_${event}DescendantDocuments`];
      fn?.call(this.syntheticActor, this.syntheticActor, collection, ...args);

      /** @deprecated since v11 */
      const legacyFn = `_${event}EmbeddedDocuments`;
      const definingClass = foundry.utils.getDefiningClass(this.syntheticActor, legacyFn);
      const isOverridden = definingClass?.name !== "ClientDocumentMixin";
      if ( isOverridden && (this.syntheticActor[legacyFn] instanceof Function) ) {
        const documentName = this.syntheticActor.constructor.hierarchy[collection].model.documentName;
        const warning = `The Actor class defines ${legacyFn} method which is deprecated in favor of a new `
          + `_${event}DescendantDocuments method.`;
        foundry.utils.logCompatibilityWarning(warning, { since: 11, until: 13 });
        this.syntheticActor[legacyFn](documentName, ...args);
      }
    }
  }
}

/**
 * The client-side Actor document which extends the common BaseActor model.
 *
 * ### Hook Events
 * {@link hookEvents.applyCompendiumArt}
 *
 * @extends foundry.documents.BaseActor
 * @mixes ClientDocumentMixin
 * @category - Documents
 *
 * @see {@link Actors}     The world-level collection of Actor documents
 * @see {@link ActorSheet} The Actor configuration application
 *
 * @example Create a new Actor
 * ```js
 * let actor = await Actor.create({
 *   name: "New Test Actor",
 *   type: "character",
 *   img: "artwork/character-profile.jpg"
 * });
 * ```
 *
 * @example Retrieve an existing Actor
 * ```js
 * let actor = game.actors.get(actorId);
 * ```
 */
class Actor extends ClientDocumentMixin(foundry.documents.BaseActor) {
  /** @inheritdoc */
  _configure(options={}) {
    super._configure(options);

    /**
     * Maintain a list of Token Documents that represent this Actor, stored by Scene.
     * @type {IterableWeakMap<Scene, IterableWeakSet<TokenDocument>>}
     * @private
     */
    Object.defineProperty(this, "_dependentTokens", { value: new foundry.utils.IterableWeakMap() });
  }

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

  /** @inheritDoc */
  _initializeSource(source, options={}) {
    source = super._initializeSource(source, options);
    // Apply configured Actor art.
    const pack = game.packs.get(options.pack);
    if ( !source._id || !pack || !game.compendiumArt.enabled ) return source;
    const uuid = pack.getUuid(source._id);
    const art = game.compendiumArt.get(uuid) ?? {};
    if ( !art.actor && !art.token ) return source;
    if ( art.actor ) source.img = art.actor;
    if ( typeof token === "string" ) source.prototypeToken.texture.src = art.token;
    else if ( art.token ) foundry.utils.mergeObject(source.prototypeToken, art.token);
    Hooks.callAll("applyCompendiumArt", this.constructor, source, pack, art);
    return source;
  }

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

  /**
   * An object that tracks which tracks the changes to the data model which were applied by active effects
   * @type {object}
   */
  overrides = this.overrides ?? {};

  /**
   * The statuses that are applied to this actor by active effects
   * @type {Set<string>}
   */
  statuses = this.statuses ?? new Set();

  /**
   * A cached array of image paths which can be used for this Actor's token.
   * Null if the list has not yet been populated.
   * @type {string[]|null}
   * @private
   */
  _tokenImages = null;

  /**
   * Cache the last drawn wildcard token to avoid repeat draws
   * @type {string|null}
   */
  _lastWildcard = null;

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

  /**
   * Provide a thumbnail image path used to represent this document.
   * @type {string}
   */
  get thumbnail() {
    return this.img;
  }

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

  /**
   * Provide an object which organizes all embedded Item instances by their type
   * @type {Record<string, Item[]>}
   */
  get itemTypes() {
    const types = Object.fromEntries(game.documentTypes.Item.map(t => [t, []]));
    for ( const item of this.items.values() ) {
      types[item.type].push(item);
    }
    return types;
  }

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

  /**
   * Test whether an Actor document is a synthetic representation of a Token (if true) or a full Document (if false)
   * @type {boolean}
   */
  get isToken() {
    if ( !this.parent ) return false;
    return this.parent instanceof TokenDocument;
  }

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

  /**
   * Retrieve the list of ActiveEffects that are currently applied to this Actor.
   * @type {ActiveEffect[]}
   */
  get appliedEffects() {
    const effects = [];
    for ( const effect of this.allApplicableEffects() ) {
      if ( effect.active ) effects.push(effect);
    }
    return effects;
  }

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

  /**
   * An array of ActiveEffect instances which are present on the Actor which have a limited duration.
   * @type {ActiveEffect[]}
   */
  get temporaryEffects() {
    const effects = [];
    for ( const effect of this.allApplicableEffects() ) {
      if ( effect.active && effect.isTemporary ) effects.push(effect);
    }
    return effects;
  }

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

  /**
   * Return a reference to the TokenDocument which owns this Actor as a synthetic override
   * @type {TokenDocument|null}
   */
  get token() {
    return this.parent instanceof TokenDocument ? this.parent : null;
  }

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

  /**
   * Whether the Actor has at least one Combatant in the active Combat that represents it.
   * @returns {boolean}
   */
  get inCombat() {
    return !!game.combat?.getCombatantsByActor(this).length;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Apply any transformations to the Actor data which are caused by ActiveEffects.
   */
  applyActiveEffects() {
    const overrides = {};
    this.statuses.clear();

    // Organize non-disabled effects by their application priority
    const changes = [];
    for ( const effect of this.allApplicableEffects() ) {
      if ( !effect.active ) continue;
      changes.push(...effect.changes.map(change => {
        const c = foundry.utils.deepClone(change);
        c.effect = effect;
        c.priority = c.priority ?? (c.mode * 10);
        return c;
      }));
      for ( const statusId of effect.statuses ) this.statuses.add(statusId);
    }
    changes.sort((a, b) => a.priority - b.priority);

    // Apply all changes
    for ( let change of changes ) {
      if ( !change.key ) continue;
      const changes = change.effect.apply(this, change);
      Object.assign(overrides, changes);
    }

    // Expand the set of final overrides
    this.overrides = foundry.utils.expandObject(overrides);
  }

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

  /**
   * Retrieve an Array of active tokens which represent this Actor in the current canvas Scene.
   * If the canvas is not currently active, or there are no linked actors, the returned Array will be empty.
   * If the Actor is a synthetic token actor, only the exact Token which it represents will be returned.
   *
   * @param {boolean} [linked=false]    Limit results to Tokens which are linked to the Actor. Otherwise, return all
   *                                    Tokens even those which are not linked.
   * @param {boolean} [document=false]  Return the Document instance rather than the PlaceableObject
   * @returns {Array<TokenDocument|Token>} An array of Token instances in the current Scene which reference this Actor.
   */
  getActiveTokens(linked=false, document=false) {
    if ( !canvas.ready ) return [];
    const tokens = [];
    for ( const t of this.getDependentTokens({ linked, scenes: canvas.scene }) ) {
      if ( t !== canvas.scene.tokens.get(t.id) ) continue;
      if ( document ) tokens.push(t);
      else if ( t.rendered ) tokens.push(t.object);
    }
    return tokens;
  }

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

  /**
   * Get all ActiveEffects that may apply to this Actor.
   * If CONFIG.ActiveEffect.legacyTransferral is true, this is equivalent to actor.effects.contents.
   * If CONFIG.ActiveEffect.legacyTransferral is false, this will also return all the transferred ActiveEffects on any
   * of the Actor's owned Items.
   * @yields {ActiveEffect}
   * @returns {Generator<ActiveEffect, void, void>}
   */
  *allApplicableEffects() {
    for ( const effect of this.effects ) {
      yield effect;
    }
    if ( CONFIG.ActiveEffect.legacyTransferral ) return;
    for ( const item of this.items ) {
      for ( const effect of item.effects ) {
        if ( effect.transfer ) yield effect;
      }
    }
  }

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

  /**
   * Return a data object which defines the data schema against which dice rolls can be evaluated.
   * By default, this is directly the Actor's system data, but systems may extend this to include additional properties.
   * If overriding or extending this method to add additional properties, care must be taken not to mutate the original
   * object.
   * @returns {object}
   */
  getRollData() {
    return this.system;
  }

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

  /**
   * Create a new Token document, not yet saved to the database, which represents the Actor.
   * @param {object} [data={}]            Additional data, such as x, y, rotation, etc. for the created token data
   * @param {object} [options={}]         The options passed to the TokenDocument constructor
   * @returns {Promise<TokenDocument>}    The created TokenDocument instance
   */
  async getTokenDocument(data={}, options={}) {
    const tokenData = this.prototypeToken.toObject();
    tokenData.actorId = this.id;

    if ( tokenData.randomImg && !data.texture?.src ) {
      let images = await this.getTokenImages();
      if ( (images.length > 1) && this._lastWildcard ) {
        images = images.filter(i => i !== this._lastWildcard);
      }
      const image = images[Math.floor(Math.random() * images.length)];
      tokenData.texture.src = this._lastWildcard = image;
    }

    if ( !tokenData.actorLink ) {
      if ( tokenData.appendNumber ) {
        // Count how many tokens are already linked to this actor
        const tokens = canvas.scene.tokens.filter(t => t.actorId === this.id);
        const n = tokens.length + 1;
        tokenData.name = `${tokenData.name} (${n})`;
      }

      if ( tokenData.prependAdjective ) {
        const adjectives = Object.values(
          foundry.utils.getProperty(game.i18n.translations, CONFIG.Token.adjectivesPrefix)
          || foundry.utils.getProperty(game.i18n._fallback, CONFIG.Token.adjectivesPrefix) || {});
        const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
        tokenData.name = `${adjective} ${tokenData.name}`;
      }
    }

    foundry.utils.mergeObject(tokenData, data);
    const cls = getDocumentClass("Token");
    return new cls(tokenData, options);
  }

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

  /**
   * Get an Array of Token images which could represent this Actor
   * @returns {Promise<string[]>}
   */
  async getTokenImages() {
    if ( !this.prototypeToken.randomImg ) return [this.prototypeToken.texture.src];
    if ( this._tokenImages ) return this._tokenImages;
    try {
      this._tokenImages = await this.constructor._requestTokenImages(this.id, {pack: this.pack});
    } catch(err) {
      this._tokenImages = [];
      Hooks.onError("Actor#getTokenImages", err, {
        msg: "Error retrieving wildcard tokens",
        log: "error",
        notify: "error"
      });
    }
    return this._tokenImages;
  }

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

  /**
   * Handle how changes to a Token attribute bar are applied to the Actor.
   * This allows for game systems to override this behavior and deploy special logic.
   * @param {string} attribute    The attribute path
   * @param {number} value        The target attribute value
   * @param {boolean} isDelta     Whether the number represents a relative change (true) or an absolute change (false)
   * @param {boolean} isBar       Whether the new value is part of an attribute bar, or just a direct value
   * @returns {Promise<documents.Actor>}  The updated Actor document
   */
  async modifyTokenAttribute(attribute, value, isDelta=false, isBar=true) {
    const attr = foundry.utils.getProperty(this.system, attribute);
    const current = isBar ? attr.value : attr;
    const update = isDelta ? current + value : value;
    if ( update === current ) return this;

    // Determine the updates to make to the actor data
    let updates;
    if ( isBar ) updates = {[`system.${attribute}.value`]: Math.clamp(update, 0, attr.max)};
    else updates = {[`system.${attribute}`]: update};

    // Allow a hook to override these changes
    const allowed = Hooks.call("modifyTokenAttribute", {attribute, value, isDelta, isBar}, updates);
    return allowed !== false ? this.update(updates) : this;
  }

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

  /** @inheritdoc */
  prepareData() {

    // Identify which special statuses had been active
    this.statuses ??= new Set();
    const specialStatuses = new Map();
    for ( const statusId of Object.values(CONFIG.specialStatusEffects) ) {
      specialStatuses.set(statusId, this.statuses.has(statusId));
    }

    super.prepareData();

    // Apply special statuses that changed to active tokens
    let tokens;
    for ( const [statusId, wasActive] of specialStatuses ) {
      const isActive = this.statuses.has(statusId);
      if ( isActive === wasActive ) continue;
      tokens ??= this.getDependentTokens({scenes: canvas.scene}).filter(t => t.rendered).map(t => t.object);
      for ( const token of tokens ) token._onApplyStatusEffect(statusId, isActive);
    }
  }

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

  /** @inheritdoc */
  prepareEmbeddedDocuments() {
    super.prepareEmbeddedDocuments();
    this.applyActiveEffects();
  }

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

  /**
   * Roll initiative for all Combatants in the currently active Combat encounter which are associated with this Actor.
   * If viewing a full Actor document, all Tokens which map to that actor will be targeted for initiative rolls.
   * If viewing a synthetic Token actor, only that particular Token will be targeted for an initiative roll.
   *
   * @param {object} options                          Configuration for how initiative for this Actor is rolled.
   * @param {boolean} [options.createCombatants=false]    Create new Combatant entries for Tokens associated with
   *                                                      this actor.
   * @param {boolean} [options.rerollInitiative=false]    Re-roll the initiative for this Actor if it has already
   *                                                      been rolled.
   * @param {object} [options.initiativeOptions={}]       Additional options passed to the Combat#rollInitiative method.
   * @returns {Promise<documents.Combat|null>}        A promise which resolves to the Combat document once rolls
   *                                                  are complete.
   */
  async rollInitiative({createCombatants=false, rerollInitiative=false, initiativeOptions={}}={}) {

    // Obtain (or create) a combat encounter
    let combat = game.combat;
    if ( !combat ) {
      if ( game.user.isGM && canvas.scene ) {
        const cls = getDocumentClass("Combat");
        combat = await cls.create({scene: canvas.scene.id, active: true});
      }
      else {
        ui.notifications.warn("COMBAT.NoneActive", {localize: true});
        return null;
      }
    }

    // Create new combatants
    if ( createCombatants ) {
      const tokens = this.getActiveTokens();
      const toCreate = [];
      if ( tokens.length ) {
        for ( let t of tokens ) {
          if ( t.inCombat ) continue;
          toCreate.push({tokenId: t.id, sceneId: t.scene.id, actorId: this.id, hidden: t.document.hidden});
        }
      } else toCreate.push({actorId: this.id, hidden: false});
      await combat.createEmbeddedDocuments("Combatant", toCreate);
    }

    // Roll initiative for combatants
    const combatants = combat.combatants.reduce((arr, c) => {
      if ( this.isToken && (c.token !== this.token) ) return arr;
      if ( !this.isToken && (c.actor !== this) ) return arr;
      if ( !rerollInitiative && (c.initiative !== null) ) return arr;
      arr.push(c.id);
      return arr;
    }, []);

    await combat.rollInitiative(combatants, initiativeOptions);
    return combat;
  }

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

  /**
   * Toggle a configured status effect for the Actor.
   * @param {string} statusId       A status effect ID defined in CONFIG.statusEffects
   * @param {object} [options={}]   Additional options which modify how the effect is created
   * @param {boolean} [options.active]        Force the effect to be active or inactive regardless of its current state
   * @param {boolean} [options.overlay=false] Display the toggled effect as an overlay
   * @returns {Promise<ActiveEffect|boolean|undefined>}  A promise which resolves to one of the following values:
   *                                 - ActiveEffect if a new effect need to be created
   *                                 - true if was already an existing effect
   *                                 - false if an existing effect needed to be removed
   *                                 - undefined if no changes need to be made
   */
  async toggleStatusEffect(statusId, {active, overlay=false}={}) {
    const status = CONFIG.statusEffects.find(e => e.id === statusId);
    if ( !status ) throw new Error(`Invalid status ID "${statusId}" provided to Actor#toggleStatusEffect`);
    const existing = [];

    // Find the effect with the static _id of the status effect
    if ( status._id ) {
      const effect = this.effects.get(status._id);
      if ( effect ) existing.push(effect.id);
    }

    // If no static _id, find all single-status effects that have this status
    else {
      for ( const effect of this.effects ) {
        const statuses = effect.statuses;
        if ( (statuses.size === 1) && statuses.has(status.id) ) existing.push(effect.id);
      }
    }

    // Remove the existing effects unless the status effect is forced active
    if ( existing.length ) {
      if ( active ) return true;
      await this.deleteEmbeddedDocuments("ActiveEffect", existing);
      return false;
    }

    // Create a new effect unless the status effect is forced inactive
    if ( !active && (active !== undefined) ) return;
    const effect = await ActiveEffect.implementation.fromStatusEffect(statusId);
    if ( overlay ) effect.updateSource({"flags.core.overlay": true});
    return ActiveEffect.implementation.create(effect, {parent: this, keepId: true});
  }

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

  /**
   * Request wildcard token images from the server and return them.
   * @param {string} actorId         The actor whose prototype token contains the wildcard image path.
   * @param {object} [options]
   * @param {string} [options.pack]  The name of the compendium the actor is in.
   * @returns {Promise<string[]>}    The list of filenames to token images that match the wildcard search.
   * @private
   */
  static _requestTokenImages(actorId, options={}) {
    return new Promise((resolve, reject) => {
      game.socket.emit("requestTokenImages", actorId, options, result => {
        if ( result.error ) return reject(new Error(result.error));
        resolve(result.files);
      });
    });
  }

  /* -------------------------------------------- */
  /*  Tokens                                      */
  /* -------------------------------------------- */

  /**
   * Get this actor's dependent tokens.
   * If the actor is a synthetic token actor, only the exact Token which it represents will be returned.
   * @param {object} [options]
   * @param {Scene|Scene[]} [options.scenes]  A single Scene, or list of Scenes to filter by.
   * @param {boolean} [options.linked]        Limit the results to tokens that are linked to the actor.
   * @returns {TokenDocument[]}
   */
  getDependentTokens({ scenes, linked=false }={}) {
    if ( this.isToken && !scenes ) return [this.token];
    if ( scenes ) scenes = Array.isArray(scenes) ? scenes : [scenes];
    else scenes = Array.from(this._dependentTokens.keys());

    if ( this.isToken ) {
      const parent = this.token.parent;
      return scenes.includes(parent) ? [this.token] : [];
    }

    const allTokens = [];
    for ( const scene of scenes ) {
      if ( !scene ) continue;
      const tokens = this._dependentTokens.get(scene);
      for ( const token of (tokens ?? []) ) {
        if ( !linked || token.actorLink ) allTokens.push(token);
      }
    }

    return allTokens;
  }

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

  /**
   * Register a token as a dependent of this actor.
   * @param {TokenDocument} token  The token.
   * @internal
   */
  _registerDependentToken(token) {
    if ( !token?.parent ) return;
    if ( !this._dependentTokens.has(token.parent) ) {
      this._dependentTokens.set(token.parent, new foundry.utils.IterableWeakSet());
    }
    const tokens = this._dependentTokens.get(token.parent);
    tokens.add(token);
  }

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

  /**
   * Remove a token from this actor's dependents.
   * @param {TokenDocument} token  The token.
   * @internal
   */
  _unregisterDependentToken(token) {
    if ( !token?.parent ) return;
    const tokens = this._dependentTokens.get(token.parent);
    tokens?.delete(token);
  }

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

  /**
   * Prune a whole scene from this actor's dependent tokens.
   * @param {Scene} scene  The scene.
   * @internal
   */
  _unregisterDependentScene(scene) {
    this._dependentTokens.delete(scene);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    // Update prototype token config references to point to the new PrototypeToken object.
    Object.values(this.apps).forEach(app => {
      if ( !(app instanceof TokenConfig) ) return;
      app.object = this.prototypeToken;
      app._previewChanges(changed.prototypeToken ?? {});
    });

    super._onUpdate(changed, options, userId);

    // Additional options only apply to base Actors
    if ( this.isToken ) return;

    this._updateDependentTokens(changed, options);

    // If the prototype token was changed, expire any cached token images
    if ( "prototypeToken" in changed ) this._tokenImages = null;

    // If ownership changed for the actor reset token control
    if ( ("permission" in changed) && tokens.length ) {
      canvas.tokens.releaseAll();
      canvas.tokens.cycleTokens(true, true);
    }
  }

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

  /** @inheritDoc */
  _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
    // If this is a grandchild Active Effect creation, call reset to re-prepare and apply active effects, then call
    // super which will invoke sheet re-rendering.
    if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset();
    super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
    this._onEmbeddedDocumentChange();
  }

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

  /** @inheritDoc */
  _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    // If this is a grandchild Active Effect update, call reset to re-prepare and apply active effects, then call
    // super which will invoke sheet re-rendering.
    if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset();
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
    this._onEmbeddedDocumentChange();
  }

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

  /** @inheritDoc */
  _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    // If this is a grandchild Active Effect deletion, call reset to re-prepare and apply active effects, then call
    // super which will invoke sheet re-rendering.
    if ( !CONFIG.ActiveEffect.legacyTransferral && (parent instanceof Item) ) this.reset();
    super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
    this._onEmbeddedDocumentChange();
  }

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

  /**
   * Additional workflows to perform when any descendant document within this Actor changes.
   * @protected
   */
  _onEmbeddedDocumentChange() {
    if ( !this.isToken ) this._updateDependentTokens();
  }

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

  /**
   * Update the active TokenDocument instances which represent this Actor.
   * @param {...any} args       Arguments forwarded to Token#_onUpdateBaseActor
   * @protected
   */
  _updateDependentTokens(...args) {
    for ( const token of this.getDependentTokens() ) {
      token._onUpdateBaseActor(...args);
    }
  }
}

/**
 * @typedef {Object} AdventureImportData
 * @property {Record<string, object[]>} toCreate    Arrays of document data to create, organized by document name
 * @property {Record<string, object[]>} toUpdate    Arrays of document data to update, organized by document name
 * @property {number} documentCount                 The total count of documents to import
 */

/**
 * @typedef {Object} AdventureImportResult
 * @property {Record<string, Document[]>} created   Documents created as a result of the import, organized by document name
 * @property {Record<string, Document[]>} updated   Documents updated as a result of the import, organized by document name
 */

/**
 * The client-side Adventure document which extends the common {@link foundry.documents.BaseAdventure} model.
 * @extends foundry.documents.BaseAdventure
 * @mixes ClientDocumentMixin
 *
 * ### Hook Events
 * {@link hookEvents.preImportAdventure} emitted by Adventure#import
 * {@link hookEvents.importAdventure} emitted by Adventure#import
 */
class Adventure extends ClientDocumentMixin(foundry.documents.BaseAdventure) {

  /** @inheritdoc */
  static fromSource(source, options={}) {
    const pack = game.packs.get(options.pack);
    if ( pack && !pack.metadata.system ) {
      // Omit system-specific documents from this Adventure's data.
      source.actors = [];
      source.items = [];
      source.folders = source.folders.filter(f => !CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(f.type));
    }
    return super.fromSource(source, options);
  }

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

  /**
   * Perform a full import workflow of this Adventure.
   * Create new and update existing documents within the World.
   * @param {object} [options]                  Options which configure and customize the import process
   * @param {boolean} [options.dialog=true]       Display a warning dialog if existing documents would be overwritten
   * @returns {Promise<AdventureImportResult>}  The import result
   */
  async import({dialog=true, ...importOptions}={}) {
    const importData = await this.prepareImport(importOptions);

    // Allow modules to preprocess adventure data or to intercept the import process
    const allowed = Hooks.call("preImportAdventure", this, importOptions, importData.toCreate, importData.toUpdate);
    if ( allowed === false ) {
      console.log(`"${this.name}" Adventure import was prevented by the "preImportAdventure" hook`);
      return {created: [], updated: []};
    }

    // Warn the user if the import operation will overwrite existing World content
    if ( !foundry.utils.isEmpty(importData.toUpdate) && dialog ) {
      const confirm = await Dialog.confirm({
        title: game.i18n.localize("ADVENTURE.ImportOverwriteTitle"),
        content: `<h4><strong>${game.i18n.localize("Warning")}:</strong></h4>
        <p>${game.i18n.format("ADVENTURE.ImportOverwriteWarning", {name: this.name})}</p>`
      });
      if ( !confirm ) return {created: [], updated: []};
    }

    // Perform the import
    const {created, updated} = await this.importContent(importData);

    // Refresh the sidebar display
    ui.sidebar.render();

    // Allow modules to perform additional post-import workflows
    Hooks.callAll("importAdventure", this, importOptions, created, updated);

    // Update the imported state of the adventure.
    const imports = game.settings.get("core", "adventureImports");
    imports[this.uuid] = true;
    await game.settings.set("core", "adventureImports", imports);

    return {created, updated};
  }

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

  /**
   * Prepare Adventure data for import into the World.
   * @param {object} [options]                 Options passed in from the import dialog to configure the import
   *                                           behavior.
   * @param {string[]} [options.importFields]  A subset of adventure fields to import.
   * @returns {Promise<AdventureImportData>}
   */
  async prepareImport({ importFields=[] }={}) {
    importFields = new Set(importFields);
    const adventureData = this.toObject();
    const toCreate = {};
    const toUpdate = {};
    let documentCount = 0;
    const importAll = !importFields.size || importFields.has("all");
    const keep = new Set();
    for ( const [field, cls] of Object.entries(Adventure.contentFields) ) {
      if ( !importAll && !importFields.has(field) ) continue;
      keep.add(cls.documentName);
      const collection = game.collections.get(cls.documentName);
      let [c, u] = adventureData[field].partition(d => collection.has(d._id));
      if ( (field === "folders") && !importAll ) {
        c = c.filter(f => keep.has(f.type));
        u = u.filter(f => keep.has(f.type));
      }
      if ( c.length ) {
        toCreate[cls.documentName] = c;
        documentCount += c.length;
      }
      if ( u.length ) {
        toUpdate[cls.documentName] = u;
        documentCount += u.length;
      }
    }
    return {toCreate, toUpdate, documentCount};
  }

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

  /**
   * Execute an Adventure import workflow, creating and updating documents in the World.
   * @param {AdventureImportData} data          Prepared adventure data to import
   * @returns {Promise<AdventureImportResult>}  The import result
   */
  async importContent({toCreate, toUpdate, documentCount}={}) {
    const created = {};
    const updated = {};

    // Display importer progress
    const importMessage = game.i18n.localize("ADVENTURE.ImportProgress");
    let nImported = 0;
    SceneNavigation.displayProgressBar({label: importMessage, pct: 1});

    // Create new documents
    for ( const [documentName, createData] of Object.entries(toCreate) ) {
      const cls = getDocumentClass(documentName);
      const docs = await cls.createDocuments(createData, {keepId: true, keepEmbeddedId: true, renderSheet: false});
      created[documentName] = docs;
      nImported += docs.length;
      SceneNavigation.displayProgressBar({label: importMessage, pct: Math.floor(nImported * 100 / documentCount)});
    }

    // Update existing documents
    for ( const [documentName, updateData] of Object.entries(toUpdate) ) {
      const cls = getDocumentClass(documentName);
      const docs = await cls.updateDocuments(updateData, {diff: false, recursive: false, noHook: true});
      updated[documentName] = docs;
      nImported += docs.length;
      SceneNavigation.displayProgressBar({label: importMessage, pct: Math.floor(nImported * 100 / documentCount)});
    }
    SceneNavigation.displayProgressBar({label: importMessage, pct: 100});
    return {created, updated};
  }
}

/**
 * The client-side AmbientLight document which extends the common BaseAmbientLight document model.
 * @extends foundry.documents.BaseAmbientLight
 * @mixes ClientDocumentMixin
 *
 * @see {@link Scene}                     The Scene document type which contains AmbientLight documents
 * @see {@link foundry.applications.sheets.AmbientLightConfig} The AmbientLight configuration application
 */
class AmbientLightDocument extends CanvasDocumentMixin(foundry.documents.BaseAmbientLight) {

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    const configs = Object.values(this.apps).filter(app => {
      return app instanceof foundry.applications.sheets.AmbientLightConfig;
    });
    configs.forEach(app => {
      if ( app.preview ) options.animate = false;
      app._previewChanges(changed);
    });
    super._onUpdate(changed, options, userId);
    configs.forEach(app => app._previewChanges());
  }

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

  /**
   * Is this ambient light source global in nature?
   * @type {boolean}
   */
  get isGlobal() {
    return !this.walls;
  }
}

/**
 * The client-side AmbientSound document which extends the common BaseAmbientSound document model.
 * @extends foundry.documents.BaseAmbientSound
 * @mixes ClientDocumentMixin
 *
 * @see {@link Scene}                   The Scene document type which contains AmbientSound documents
 * @see {@link foundry.applications.sheets.AmbientSoundConfig} The AmbientSound configuration application
 */
class AmbientSoundDocument extends CanvasDocumentMixin(foundry.documents.BaseAmbientSound) {}

/**
 * The client-side Card document which extends the common BaseCard document model.
 * @extends foundry.documents.BaseCard
 * @mixes ClientDocumentMixin
 *
 * @see {@link Cards}                    The Cards document type which contains Card embedded documents
 * @see {@link CardConfig}               The Card configuration application
 */
class Card extends ClientDocumentMixin(foundry.documents.BaseCard) {

  /**
   * The current card face
   * @type {CardFaceData|null}
   */
  get currentFace() {
    if ( this.face === null ) return null;
    const n = Math.clamp(this.face, 0, this.faces.length-1);
    return this.faces[n] || null;
  }

  /**
   * The image of the currently displayed card face or back
   * @type {string}
   */
  get img() {
    return this.currentFace?.img || this.back.img || Card.DEFAULT_ICON;
  }

  /**
   * A reference to the source Cards document which defines this Card.
   * @type {Cards|null}
   */
  get source() {
    return this.parent?.type === "deck" ? this.parent : this.origin;
  }

  /**
   * A convenience property for whether the Card is within its source Cards stack. Cards in decks are always
   * considered home.
   * @type {boolean}
   */
  get isHome() {
    return (this.parent?.type === "deck") || (this.origin === this.parent);
  }

  /**
   * Whether to display the face of this card?
   * @type {boolean}
   */
  get showFace() {
    return this.faces[this.face] !== undefined;
  }

  /**
   * Does this Card have a next face available to flip to?
   * @type {boolean}
   */
  get hasNextFace() {
    return (this.face === null) || (this.face < this.faces.length - 1);
  }

  /**
   * Does this Card have a previous face available to flip to?
   * @type {boolean}
   */
  get hasPreviousFace() {
    return this.face !== null;
  }

  /* -------------------------------------------- */
  /*  Core Methods                                */
  /* -------------------------------------------- */

  /** @override */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.back.img ||= this.source?.img || Card.DEFAULT_ICON;
    this.name = (this.showFace ? (this.currentFace.name || this._source.name) : this.back.name)
      || game.i18n.format("CARD.Unknown", {source: this.source?.name || game.i18n.localize("Unknown")});
  }

  /* -------------------------------------------- */
  /*  API Methods                                 */
  /* -------------------------------------------- */

  /**
   * Flip this card to some other face. A specific face may be requested, otherwise:
   * If the card currently displays a face the card is flipped to the back.
   * If the card currently displays the back it is flipped to the first face.
   * @param {number|null} [face]      A specific face to flip the card to
   * @returns {Promise<Card>}         A reference to this card after the flip operation is complete
   */
  async flip(face) {

    // Flip to an explicit face
    if ( Number.isNumeric(face) || (face === null) ) return this.update({face});

    // Otherwise, flip to default
    return this.update({face: this.face === null ? 0 : null});
  }

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

  /**
   * Pass this Card to some other Cards document.
   * @param {Cards} to                A new Cards document this card should be passed to
   * @param {object} [options={}]     Options which modify the pass operation
   * @param {object} [options.updateData={}]  Modifications to make to the Card as part of the pass operation,
   *                                  for example the displayed face
   * @returns {Promise<Card>}         A reference to this card after it has been passed to another parent document
   */
  async pass(to, {updateData={}, ...options}={}) {
    const created = await this.parent.pass(to, [this.id], {updateData, action: "pass", ...options});
    return created[0];
  }

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

  /**
   * @alias Card#pass
   * @see Card#pass
   * @inheritdoc
   */
  async play(to, {updateData={}, ...options}={}) {
    const created = await this.parent.pass(to, [this.id], {updateData, action: "play", ...options});
    return created[0];
  }

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

  /**
   * @alias Card#pass
   * @see Card#pass
   * @inheritdoc
   */
  async discard(to, {updateData={}, ...options}={}) {
    const created = await this.parent.pass(to, [this.id], {updateData, action: "discard", ...options});
    return created[0];
  }

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

  /**
   * Recall this Card to its original Cards parent.
   * @param {object} [options={}]   Options which modify the recall operation
   * @returns {Promise<Card>}       A reference to the recalled card belonging to its original parent
   */
  async recall(options={}) {

    // Mark the original card as no longer drawn
    const original = this.isHome ? this : this.source?.cards.get(this.id);
    if ( original ) await original.update({drawn: false});

    // Delete this card if it's not the original
    if ( !this.isHome ) await this.delete();
    return original;
  }

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

  /**
   * Create a chat message which displays this Card.
   * @param {object} [messageData={}] Additional data which becomes part of the created ChatMessageData
   * @param {object} [options={}]     Options which modify the message creation operation
   * @returns {Promise<ChatMessage>}  The created chat message
   */
  async toMessage(messageData={}, options={}) {
    messageData = foundry.utils.mergeObject({
      content: `<div class="card-draw flexrow">
        <img class="card-face" src="${this.img}" alt="${this.name}"/>
        <h4 class="card-name">${this.name}</h4>
      </div>`
    }, messageData);
    return ChatMessage.implementation.create(messageData, options);
  }
}

/**
 * The client-side Cards document which extends the common BaseCards model.
 * Each Cards document contains CardsData which defines its data schema.
 * @extends foundry.documents.BaseCards
 * @mixes ClientDocumentMixin
 *
 * @see {@link CardStacks}                        The world-level collection of Cards documents
 * @see {@link CardsConfig}                       The Cards configuration application
 */
class Cards extends ClientDocumentMixin(foundry.documents.BaseCards) {

  /**
   * Provide a thumbnail image path used to represent this document.
   * @type {string}
   */
  get thumbnail() {
    return this.img;
  }

  /**
   * The Card documents within this stack which are available to be drawn.
   * @type {Card[]}
   */
  get availableCards() {
    return this.cards.filter(c => (this.type !== "deck") || !c.drawn);
  }

  /**
   * The Card documents which belong to this stack but have already been drawn.
   * @type {Card[]}
   */
  get drawnCards() {
    return this.cards.filter(c => c.drawn);
  }

  /**
   * Returns the localized Label for the type of Card Stack this is
   * @type {string}
   */
  get typeLabel() {
    switch ( this.type ) {
      case "deck": return game.i18n.localize("CARDS.TypeDeck");
      case "hand": return game.i18n.localize("CARDS.TypeHand");
      case "pile": return game.i18n.localize("CARDS.TypePile");
      default: throw new Error(`Unexpected type ${this.type}`);
    }
  }

  /**
   * Can this Cards document be cloned in a duplicate workflow?
   * @type {boolean}
   */
  get canClone() {
    if ( this.type === "deck" ) return true;
    else return this.cards.size === 0;
  }

  /* -------------------------------------------- */
  /*  API Methods                                 */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static async createDocuments(data=[], context={}) {
    if ( context.keepEmbeddedIds === undefined ) context.keepEmbeddedIds = false;
    return super.createDocuments(data, context);
  }

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

  /**
   * Deal one or more cards from this Cards document to each of a provided array of Cards destinations.
   * Cards are allocated from the top of the deck in cyclical order until the required number of Cards have been dealt.
   * @param {Cards[]} to              An array of other Cards documents to which cards are dealt
   * @param {number} [number=1]       The number of cards to deal to each other document
   * @param {object} [options={}]     Options which modify how the deal operation is performed
   * @param {number} [options.how=0]          How to draw, a value from CONST.CARD_DRAW_MODES
   * @param {object} [options.updateData={}]  Modifications to make to each Card as part of the deal operation,
   *                                          for example the displayed face
   * @param {string} [options.action=deal]    The name of the action being performed, used as part of the dispatched
   *                                          Hook event
   * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
   * @returns {Promise<Cards>}        This Cards document after the deal operation has completed
   */
  async deal(to, number=1, {action="deal", how=0, updateData={}, chatNotification=true}={}) {

    // Validate the request
    if ( !to.every(d => d instanceof Cards) ) {
      throw new Error("You must provide an array of Cards documents as the destinations for the Cards#deal operation");
    }

    // Draw from the sorted stack
    const total = number * to.length;
    const drawn = this._drawCards(total, how);

    // Allocate cards to each destination
    const toCreate = to.map(() => []);
    const toUpdate = [];
    const toDelete = [];
    for ( let i=0; i<total; i++ ) {
      const n = i % to.length;
      const card = drawn[i];
      const createData = foundry.utils.mergeObject(card.toObject(), updateData);
      if ( card.isHome || !createData.origin ) createData.origin = this.id;
      createData.drawn = true;
      toCreate[n].push(createData);
      if ( card.isHome ) toUpdate.push({_id: card.id, drawn: true});
      else toDelete.push(card.id);
    }

    const allowed = Hooks.call("dealCards", this, to, {
      action: action,
      toCreate: toCreate,
      fromUpdate: toUpdate,
      fromDelete: toDelete
    });
    if ( allowed === false ) {
      console.debug(`${vtt} | The Cards#deal operation was prevented by a hooked function`);
      return this;
    }

    // Perform database operations
    const promises = to.map((cards, i) => {
      return cards.createEmbeddedDocuments("Card", toCreate[i], {keepId: true});
    });
    promises.push(this.updateEmbeddedDocuments("Card", toUpdate));
    promises.push(this.deleteEmbeddedDocuments("Card", toDelete));
    await Promise.all(promises);

    // Dispatch chat notification
    if ( chatNotification ) {
      const chatActions = {
        deal: "CARDS.NotifyDeal",
        pass: "CARDS.NotifyPass"
      };
      this._postChatNotification(this, chatActions[action], {number, link: to.map(t => t.link).join(", ")});
    }
    return this;
  }

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

  /**
   * Pass an array of specific Card documents from this document to some other Cards stack.
   * @param {Cards} to                Some other Cards document that is the destination for the pass operation
   * @param {string[]} ids            The embedded Card ids which should be passed
   * @param {object} [options={}]     Additional options which modify the pass operation
   * @param {object} [options.updateData={}]  Modifications to make to each Card as part of the pass operation,
   *                                          for example the displayed face
   * @param {string} [options.action=pass]    The name of the action being performed, used as part of the dispatched
   *                                          Hook event
   * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
   * @returns {Promise<Card[]>}       An array of the Card embedded documents created within the destination stack
   */
  async pass(to, ids, {updateData={}, action="pass", chatNotification=true}={}) {
    if ( !(to instanceof Cards) ) {
      throw new Error("You must provide a Cards document as the recipient for the Cards#pass operation");
    }

    // Allocate cards to different required operations
    const toCreate = [];
    const toUpdate = [];
    const fromUpdate = [];
    const fromDelete = [];

    // Validate the provided cards
    for ( let id of ids ) {
      const card = this.cards.get(id, {strict: true});
      const deletedFromOrigin = card.origin && !card.origin.cards.get(id);

      // Prevent drawing cards from decks multiple times
      if ( (this.type === "deck") && card.isHome && card.drawn ) {
        throw new Error(`You may not pass Card ${id} which has already been drawn`);
      }

      // Return drawn cards to their origin deck
      if ( (card.origin === to) && !deletedFromOrigin ) {
        toUpdate.push({_id: card.id, drawn: false});
      }

      // Create cards in a new destination
      else {
        const createData = foundry.utils.mergeObject(card.toObject(), updateData);
        const copyCard = (card.isHome && (to.type === "deck"));
        if ( copyCard ) createData.origin = to.id;
        else if ( card.isHome || !createData.origin ) createData.origin = this.id;
        createData.drawn = !copyCard && !deletedFromOrigin;
        toCreate.push(createData);
      }

      // Update cards in their home deck
      if ( card.isHome && (to.type !== "deck") ) fromUpdate.push({_id: card.id, drawn: true});

      // Remove cards from their current stack
      else if ( !card.isHome ) fromDelete.push(card.id);
    }

    const allowed = Hooks.call("passCards", this, to, {action, toCreate, toUpdate, fromUpdate, fromDelete});
    if ( allowed === false ) {
      console.debug(`${vtt} | The Cards#pass operation was prevented by a hooked function`);
      return [];
    }

    // Perform database operations
    const created = to.createEmbeddedDocuments("Card", toCreate, {keepId: true});
    await Promise.all([
      created,
      to.updateEmbeddedDocuments("Card", toUpdate),
      this.updateEmbeddedDocuments("Card", fromUpdate),
      this.deleteEmbeddedDocuments("Card", fromDelete)
    ]);

    // Dispatch chat notification
    if ( chatNotification ) {
      const chatActions = {
        pass: "CARDS.NotifyPass",
        play: "CARDS.NotifyPlay",
        discard: "CARDS.NotifyDiscard",
        draw: "CARDS.NotifyDraw"
      };
      const chatFrom = action === "draw" ? to : this;
      const chatTo = action === "draw" ? this : to;
      this._postChatNotification(chatFrom, chatActions[action], {number: ids.length, link: chatTo.link});
    }
    return created;
  }

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

  /**
   * Draw one or more cards from some other Cards document.
   * @param {Cards} from              Some other Cards document from which to draw
   * @param {number} [number=1]       The number of cards to draw
   * @param {object} [options={}]     Options which modify how the draw operation is performed
   * @param {number} [options.how=0]          How to draw, a value from CONST.CARD_DRAW_MODES
   * @param {object} [options.updateData={}]  Modifications to make to each Card as part of the draw operation,
   *                                          for example the displayed face
   * @returns {Promise<Card[]>}       An array of the Card documents which were drawn
   */
  async draw(from, number=1, {how=0, updateData={}, ...options}={}) {
    if ( !(from instanceof Cards) || (from === this) ) {
      throw new Error("You must provide some other Cards document as the source for the Cards#draw operation");
    }
    const toDraw = from._drawCards(number, how);
    return from.pass(this, toDraw.map(c => c.id), {updateData, action: "draw", ...options});
  }

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

  /**
   * Shuffle this Cards stack, randomizing the sort order of all the cards it contains.
   * @param {object} [options={}]     Options which modify how the shuffle operation is performed.
   * @param {object} [options.updateData={}]  Modifications to make to each Card as part of the shuffle operation,
   *                                          for example the displayed face.
   * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
   * @returns {Promise<Cards>}        The Cards document after the shuffle operation has completed
   */
  async shuffle({updateData={}, chatNotification=true}={}) {
    const order = this.cards.map(c => [foundry.dice.MersenneTwister.random(), c]);
    order.sort((a, b) => a[0] - b[0]);
    const toUpdate = order.map((x, i) => {
      const card = x[1];
      return foundry.utils.mergeObject({_id: card.id, sort: i}, updateData);
    });

    // Post a chat notification and return
    await this.updateEmbeddedDocuments("Card", toUpdate);
    if ( chatNotification ) {
      this._postChatNotification(this, "CARDS.NotifyShuffle", {link: this.link});
    }
    return this;
  }

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

  /**
   * Recall the Cards stack, retrieving all original cards from other stacks where they may have been drawn if this is a
   * deck, otherwise returning all the cards in this stack to the decks where they originated.
   * @param {object} [options={}]             Options which modify the recall operation
   * @param {object} [options.updateData={}]  Modifications to make to each Card as part of the recall operation,
   *                                          for example the displayed face
   * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
   * @returns {Promise<Cards>}                The Cards document after the recall operation has completed.
   */
  async recall(options) {
    if ( this.type === "deck" ) return this._resetDeck(options);
    return this._resetStack(options);
  }

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

  /**
   * Perform a reset operation for a deck, retrieving all original cards from other stacks where they may have been
   * drawn.
   * @param {object} [options={}]              Options which modify the reset operation.
   * @param {object} [options.updateData={}]           Modifications to make to each Card as part of the reset operation
   * @param {boolean} [options.chatNotification=true]  Create a ChatMessage which notifies that this action has occurred
   * @returns {Promise<Cards>}                 The Cards document after the reset operation has completed.
   * @private
   */
  async _resetDeck({updateData={}, chatNotification=true}={}) {

    // Recover all cards which belong to this stack
    for ( let cards of game.cards ) {
      if ( cards === this ) continue;
      const toDelete = [];
      for ( let c of cards.cards ) {
        if ( c.origin === this ) {
          toDelete.push(c.id);
        }
      }
      if ( toDelete.length ) await cards.deleteEmbeddedDocuments("Card", toDelete);
    }

    // Mark all cards as not drawn
    const cards = this.cards.contents;
    cards.sort(this.sortStandard.bind(this));
    const toUpdate = cards.map(card => {
      return foundry.utils.mergeObject({_id: card.id, drawn: false}, updateData);
    });

    // Post a chat notification and return
    await this.updateEmbeddedDocuments("Card", toUpdate);
    if ( chatNotification ) {
      this._postChatNotification(this, "CARDS.NotifyReset", {link: this.link});
    }
    return this;
  }

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

  /**
   * Return all cards in this stack to their original decks.
   * @param {object} [options={}]              Options which modify the return operation.
   * @param {object} [options.updateData={}]          Modifications to make to each Card as part of the return operation
   * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred
   * @returns {Promise<Cards>}                 The Cards document after the return operation has completed.
   * @private
   */
  async _resetStack({updateData={}, chatNotification=true}={}) {

    // Allocate cards to different required operations.
    const toUpdate = {};
    const fromDelete = [];
    for ( const card of this.cards ) {
      if ( card.isHome || !card.origin ) continue;

      // Return drawn cards to their origin deck
      if ( card.origin.cards.get(card.id) ) {
        if ( !toUpdate[card.origin.id] ) toUpdate[card.origin.id] = [];
        const update = foundry.utils.mergeObject(updateData, {_id: card.id, drawn: false}, {inplace: false});
        toUpdate[card.origin.id].push(update);
      }

      // Remove cards from the current stack.
      fromDelete.push(card.id);
    }

    const allowed = Hooks.call("returnCards", this, fromDelete.map(id => this.cards.get(id)), {toUpdate, fromDelete});
    if ( allowed === false ) {
      console.debug(`${vtt} | The Cards#return operation was prevented by a hooked function.`);
      return this;
    }

    // Perform database operations.
    const updates = Object.entries(toUpdate).map(([origin, u]) => {
      return game.cards.get(origin).updateEmbeddedDocuments("Card", u);
    });
    await Promise.all([...updates, this.deleteEmbeddedDocuments("Card", fromDelete)]);

    // Dispatch chat notification
    if ( chatNotification ) this._postChatNotification(this, "CARDS.NotifyReturn", {link: this.link});
    return this;
  }

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

  /**
   * A sorting function that is used to determine the standard order of Card documents within an un-shuffled stack.
   * Sorting with "en" locale to ensure the same order regardless of which client sorts the deck.
   * @param {Card} a     The card being sorted
   * @param {Card} b     Another card being sorted against
   * @returns {number}
   * @protected
   */
  sortStandard(a, b) {
    if ( (a.suit ?? "") === (b.suit ?? "") ) return ((a.value ?? -Infinity) - (b.value ?? -Infinity)) || 0;
    return (a.suit ?? "").compare(b.suit ?? "");
  }

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

  /**
   * A sorting function that is used to determine the order of Card documents within a shuffled stack.
   * @param {Card} a     The card being sorted
   * @param {Card} b     Another card being sorted against
   * @returns {number}
   * @protected
   */
  sortShuffled(a, b) {
    return a.sort - b.sort;
  }

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

  /**
   * An internal helper method for drawing a certain number of Card documents from this Cards stack.
   * @param {number} number       The number of cards to draw
   * @param {number} how          A draw mode from CONST.CARD_DRAW_MODES
   * @returns {Card[]}            An array of drawn Card documents
   * @protected
   */
  _drawCards(number, how) {

    // Confirm that sufficient cards are available
    let available = this.availableCards;
    if ( available.length < number ) {
      throw new Error(`There are not ${number} available cards remaining in Cards [${this.id}]`);
    }

    // Draw from the stack
    let drawn;
    switch ( how ) {
      case CONST.CARD_DRAW_MODES.FIRST:
        available.sort(this.sortShuffled.bind(this));
        drawn = available.slice(0, number);
        break;
      case CONST.CARD_DRAW_MODES.LAST:
        available.sort(this.sortShuffled.bind(this));
        drawn = available.slice(-number);
        break;
      case CONST.CARD_DRAW_MODES.RANDOM:
        const shuffle = available.map(c => [Math.random(), c]);
        shuffle.sort((a, b) => a[0] - b[0]);
        drawn = shuffle.slice(-number).map(x => x[1]);
        break;
    }
    return drawn;
  }

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

  /**
   * Create a ChatMessage which provides a notification of the operation which was just performed.
   * Visibility of the resulting message is linked to the default roll mode selected in the chat log dropdown.
   * @param {Cards} source        The source Cards document from which the action originated
   * @param {string} action       The localization key which formats the chat message notification
   * @param {object} context      Data passed to the Localization#format method for the localization key
   * @returns {ChatMessage}       A created ChatMessage document
   * @private
   */
  _postChatNotification(source, action, context) {
    const messageData = {
      style: CONST.CHAT_MESSAGE_STYLES.OTHER,
      speaker: {user: game.user},
      content: `
      <div class="cards-notification flexrow">
        <img class="icon" src="${source.thumbnail}" alt="${source.name}">
        <p>${game.i18n.format(action, context)}</p>
      </div>`
    };
    ChatMessage.applyRollMode(messageData, game.settings.get("core", "rollMode"));
    return ChatMessage.implementation.create(messageData);
  }

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

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;
    for ( const card of this.cards ) {
      card.updateSource({drawn: false});
    }
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    if ( "type" in changed ) {
      this.sheet?.close();
      this._sheet = undefined;
    }
    super._onUpdate(changed, options, userId);
  }

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

  /** @inheritDoc */
  async _preDelete(options, user) {
    await this.recall();
    return super._preDelete(options, user);
  }

  /* -------------------------------------------- */
  /*  Interaction Dialogs                         */
  /* -------------------------------------------- */

  /**
   * Display a dialog which prompts the user to deal cards to some number of hand-type Cards documents.
   * @see {@link Cards#deal}
   * @returns {Promise<Cards|null>}
   */
  async dealDialog() {
    const hands = game.cards.filter(c => (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED"));
    if ( !hands.length ) {
      ui.notifications.warn("CARDS.DealWarnNoTargets", {localize: true});
      return this;
    }

    // Construct the dialog HTML
    const html = await renderTemplate("templates/cards/dialog-deal.html", {
      hands: hands,
      modes: {
        [CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
        [CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
        [CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
      }
    });

    // Display the prompt
    return Dialog.prompt({
      title: game.i18n.localize("CARDS.DealTitle"),
      label: game.i18n.localize("CARDS.Deal"),
      content: html,
      callback: html => {
        const form = html.querySelector("form.cards-dialog");
        const fd = new FormDataExtended(form).object;
        if ( !fd.to ) return this;
        const toIds = fd.to instanceof Array ? fd.to : [fd.to];
        const to = toIds.reduce((arr, id) => {
          const c = game.cards.get(id);
          if ( c ) arr.push(c);
          return arr;
        }, []);
        const options = {how: fd.how, updateData: fd.down ? {face: null} : {}};
        return this.deal(to, fd.number, options).catch(err => {
          ui.notifications.error(err.message);
          return this;
        });
      },
      rejectClose: false,
      options: {jQuery: false}
    });
  }

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

  /**
   * Display a dialog which prompts the user to draw cards from some other deck-type Cards documents.
   * @see {@link Cards#draw}
   * @returns {Promise<Card[]|null>}
   */
  async drawDialog() {
    const decks = game.cards.filter(c => (c.type === "deck") && c.testUserPermission(game.user, "LIMITED"));
    if ( !decks.length ) {
      ui.notifications.warn("CARDS.DrawWarnNoSources", {localize: true});
      return [];
    }

    // Construct the dialog HTML
    const html = await renderTemplate("templates/cards/dialog-draw.html", {
      decks: decks,
      modes: {
        [CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
        [CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
        [CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
      }
    });

    // Display the prompt
    return Dialog.prompt({
      title: game.i18n.localize("CARDS.DrawTitle"),
      label: game.i18n.localize("CARDS.Draw"),
      content: html,
      callback: html => {
        const form = html.querySelector("form.cards-dialog");
        const fd = new FormDataExtended(form).object;
        const from = game.cards.get(fd.from);
        const options = {how: fd.how, updateData: fd.down ? {face: null} : {}};
        return this.draw(from, fd.number, options).catch(err => {
          ui.notifications.error(err.message);
          return [];
        });
      },
      rejectClose: false,
      options: {jQuery: false}
    });
  }

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

  /**
   * Display a dialog which prompts the user to pass cards from this document to some other Cards document.
   * @see {@link Cards#deal}
   * @returns {Promise<Cards|null>}
   */
  async passDialog() {
    const cards = game.cards.filter(c => (c !== this) && (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED"));
    if ( !cards.length ) {
      ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true});
      return this;
    }

    // Construct the dialog HTML
    const html = await renderTemplate("templates/cards/dialog-pass.html", {
      cards: cards,
      modes: {
        [CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop",
        [CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom",
        [CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom"
      }
    });

    // Display the prompt
    return Dialog.prompt({
      title: game.i18n.localize("CARDS.PassTitle"),
      label: game.i18n.localize("CARDS.Pass"),
      content: html,
      callback: html => {
        const form = html.querySelector("form.cards-dialog");
        const fd = new FormDataExtended(form).object;
        const to = game.cards.get(fd.to);
        const options = {action: "pass", how: fd.how, updateData: fd.down ? {face: null} : {}};
        return this.deal([to], fd.number, options).catch(err => {
          ui.notifications.error(err.message);
          return this;
        });
      },
      rejectClose: false,
      options: {jQuery: false}
    });
  }

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

  /**
   * Display a dialog which prompts the user to play a specific Card to some other Cards document
   * @see {@link Cards#pass}
   * @param {Card} card     The specific card being played as part of this dialog
   * @returns {Promise<Card[]|null>}
   */
  async playDialog(card) {
    const cards = game.cards.filter(c => (c !== this) && (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED"));
    if ( !cards.length ) {
      ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true});
      return [];
    }

    // Construct the dialog HTML
    const html = await renderTemplate("templates/cards/dialog-play.html", {card, cards});

    // Display the prompt
    return Dialog.prompt({
      title: game.i18n.localize("CARD.Play"),
      label: game.i18n.localize("CARD.Play"),
      content: html,
      callback: html => {
        const form = html.querySelector("form.cards-dialog");
        const fd = new FormDataExtended(form).object;
        const to = game.cards.get(fd.to);
        const options = {action: "play", updateData: fd.down ? {face: null} : {}};
        return this.pass(to, [card.id], options).catch(err => {
          ui.notifications.error(err.message);
          return [];
        });
      },
      rejectClose: false,
      options: {jQuery: false}
    });
  }

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

  /**
   * Display a confirmation dialog for whether or not the user wishes to reset a Cards stack
   * @see {@link Cards#recall}
   * @returns {Promise<Cards|false|null>}
   */
  async resetDialog() {
    return Dialog.confirm({
      title: game.i18n.localize("CARDS.Reset"),
      content: `<p>${game.i18n.format(`CARDS.${this.type === "deck" ? "Reset" : "Return"}Confirm`, {name: this.name})}</p>`,
      yes: () => this.recall()
    });
  }

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

  /** @inheritdoc */
  async deleteDialog(options={}) {
    if ( !this.drawnCards.length ) return super.deleteDialog(options);
    const type = this.typeLabel;
    return new Promise(resolve => {
      const dialog = new Dialog({
        title: `${game.i18n.format("DOCUMENT.Delete", {type})}: ${this.name}`,
        content: `
          <h4>${game.i18n.localize("CARDS.DeleteCannot")}</h4>
          <p>${game.i18n.format("CARDS.DeleteMustReset", {type})}</p>
        `,
        buttons: {
          reset: {
            icon: '<i class="fas fa-undo"></i>',
            label: game.i18n.localize("CARDS.DeleteReset"),
            callback: () => resolve(this.delete())
          },
          cancel: {
            icon: '<i class="fas fa-times"></i>',
            label: game.i18n.localize("Cancel"),
            callback: () => resolve(false)
          }
        },
        close: () => resolve(null),
        default: "reset"
      }, options);
      dialog.render(true);
    });
  }

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

  /** @override */
  static async createDialog(data={}, {parent=null, pack=null, types, ...options}={}) {
    if ( types ) {
      if ( types.length === 0 ) throw new Error("The array of sub-types to restrict to must not be empty");
      for ( const type of types ) {
        if ( !this.TYPES.includes(type) ) throw new Error(`Invalid ${this.documentName} sub-type: "${type}"`);
      }
    }

    // Collect data
    const documentTypes = this.TYPES.filter(t => types?.includes(t) !== false);
    let collection;
    if ( !parent ) {
      if ( pack ) collection = game.packs.get(pack);
      else collection = game.collections.get(this.documentName);
    }
    const folders = collection?._formatFolderSelectOptions() ?? [];
    const label = game.i18n.localize(this.metadata.label);
    const title = game.i18n.format("DOCUMENT.Create", {type: label});
    const type = data.type || documentTypes[0];

    // Render the document creation form
    const html = await renderTemplate("templates/sidebar/cards-create.html", {
      folders,
      name: data.name || "",
      defaultName: this.implementation.defaultName({type, parent, pack}),
      folder: data.folder,
      hasFolders: folders.length >= 1,
      type,
      types: Object.fromEntries(documentTypes.map(type => {
        const label = CONFIG[this.documentName]?.typeLabels?.[type];
        return [type, label && game.i18n.has(label) ? game.i18n.localize(label) : type];
      }).sort((a, b) => a[1].localeCompare(b[1], game.i18n.lang))),
      hasTypes: true,
      presets: CONFIG.Cards.presets
    });

    // Render the confirmation dialog window
    return Dialog.prompt({
      title: title,
      content: html,
      label: title,
      render: html => {
        html[0].querySelector('[name="type"]').addEventListener("change", e => {
          html[0].querySelector('[name="name"]').placeholder = this.implementation.defaultName(
            {type: e.target.value, parent, pack});
        });
      },
      callback: async html => {
        const form = html[0].querySelector("form");
        const fd = new FormDataExtended(form);
        foundry.utils.mergeObject(data, fd.object, {inplace: true});
        if ( !data.folder ) delete data.folder;
        if ( !data.name?.trim() ) data.name = this.implementation.defaultName({type: data.type, parent, pack});
        const preset = CONFIG.Cards.presets[data.preset];
        if ( preset && (preset.type === data.type) ) {
          const presetData = await fetch(preset.src).then(r => r.json());
          data = foundry.utils.mergeObject(presetData, data);
        }
        return this.implementation.create(data, {parent, pack, renderSheet: true});
      },
      rejectClose: false,
      options
    });
  }
}

/**
 * The client-side ChatMessage document which extends the common BaseChatMessage model.
 *
 * @extends foundry.documents.BaseChatMessage
 * @mixes ClientDocumentMixin
 *
 * @see {@link Messages}                The world-level collection of ChatMessage documents
 *
 * @property {Roll[]} rolls                       The prepared array of Roll instances
 */
class ChatMessage extends ClientDocumentMixin(foundry.documents.BaseChatMessage) {

  /**
   * Is the display of dice rolls in this message collapsed (false) or expanded (true)
   * @type {boolean}
   * @private
   */
  _rollExpanded = false;

  /**
   * Is this ChatMessage currently displayed in the sidebar ChatLog?
   * @type {boolean}
   */
  logged = false;

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

  /**
   * Return the recommended String alias for this message.
   * The alias could be a Token name in the case of in-character messages or dice rolls.
   * Alternatively it could be the name of a User in the case of OOC chat or whispers.
   * @type {string}
   */
  get alias() {
    const speaker = this.speaker;
    if ( speaker.alias ) return speaker.alias;
    else if ( game.actors.has(speaker.actor) ) return game.actors.get(speaker.actor).name;
    else return this.author?.name ?? game.i18n.localize("CHAT.UnknownUser");
  }

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

  /**
   * Is the current User the author of this message?
   * @type {boolean}
   */
  get isAuthor() {
    return game.user === this.author;
  }

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

  /**
   * Return whether the content of the message is visible to the current user.
   * For certain dice rolls, for example, the message itself may be visible while the content of that message is not.
   * @type {boolean}
   */
  get isContentVisible() {
    if ( this.isRoll ) {
      const whisper = this.whisper || [];
      const isBlind = whisper.length && this.blind;
      if ( whisper.length ) return whisper.includes(game.user.id) || (this.isAuthor && !isBlind);
      return true;
    }
    else return this.visible;
  }

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

  /**
   * Does this message contain dice rolls?
   * @type {boolean}
   */
  get isRoll() {
    return this.rolls.length > 0;
  }

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

  /**
   * Return whether the ChatMessage is visible to the current User.
   * Messages may not be visible if they are private whispers.
   * @type {boolean}
   */
  get visible() {
    if ( this.whisper.length ) {
      if ( this.isRoll ) return true;
      return this.isAuthor || (this.whisper.indexOf(game.user.id) !== -1);
    }
    return true;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  prepareDerivedData() {
    super.prepareDerivedData();

    // Create Roll instances for contained dice rolls
    this.rolls = this.rolls.reduce((rolls, rollData) => {
      try {
        rolls.push(Roll.fromData(rollData));
      } catch(err) {
        Hooks.onError("ChatMessage#rolls", err, {rollData, log: "error"});
      }
      return rolls;
    }, []);
  }

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

  /**
   * Transform a provided object of ChatMessage data by applying a certain rollMode to the data object.
   * @param {object} chatData     The object of ChatMessage data prior to applying a rollMode preference
   * @param {string} rollMode     The rollMode preference to apply to this message data
   * @returns {object}            The modified ChatMessage data with rollMode preferences applied
   */
  static applyRollMode(chatData, rollMode) {
    const modes = CONST.DICE_ROLL_MODES;
    if ( rollMode === "roll" ) rollMode = game.settings.get("core", "rollMode");
    if ( [modes.PRIVATE, modes.BLIND].includes(rollMode) ) {
      chatData.whisper = ChatMessage.getWhisperRecipients("GM").map(u => u.id);
    }
    else if ( rollMode === modes.SELF ) chatData.whisper = [game.user.id];
    else if ( rollMode === modes.PUBLIC ) chatData.whisper = [];
    chatData.blind = rollMode === modes.BLIND;
    return chatData;
  }

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

  /**
   * Update the data of a ChatMessage instance to apply a requested rollMode
   * @param {string} rollMode     The rollMode preference to apply to this message data
   */
  applyRollMode(rollMode) {
    const updates = {};
    this.constructor.applyRollMode(updates, rollMode);
    this.updateSource(updates);
  }

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

  /**
   * Attempt to determine who is the speaking character (and token) for a certain Chat Message
   * First assume that the currently controlled Token is the speaker
   *
   * @param {object} [options={}]   Options which affect speaker identification
   * @param {Scene} [options.scene]         The Scene in which the speaker resides
   * @param {Actor} [options.actor]         The Actor who is speaking
   * @param {TokenDocument} [options.token] The Token who is speaking
   * @param {string} [options.alias]        The name of the speaker to display
   *
   * @returns {object}              The identified speaker data
   */
  static getSpeaker({scene, actor, token, alias}={}) {

    // CASE 1 - A Token is explicitly provided
    const hasToken = (token instanceof Token) || (token instanceof TokenDocument);
    if ( hasToken ) return this._getSpeakerFromToken({token, alias});
    const hasActor = actor instanceof Actor;
    if ( hasActor && actor.isToken ) return this._getSpeakerFromToken({token: actor.token, alias});

    // CASE 2 - An Actor is explicitly provided
    if ( hasActor ) {
      alias = alias || actor.name;
      const tokens = actor.getActiveTokens();
      if ( !tokens.length ) return this._getSpeakerFromActor({scene, actor, alias});
      const controlled = tokens.filter(t => t.controlled);
      token = controlled.length ? controlled.shift() : tokens.shift();
      return this._getSpeakerFromToken({token: token.document, alias});
    }

    // CASE 3 - Not the viewed Scene
    else if ( ( scene instanceof Scene ) && !scene.isView ) {
      const char = game.user.character;
      if ( char ) return this._getSpeakerFromActor({scene, actor: char, alias});
      return this._getSpeakerFromUser({scene, user: game.user, alias});
    }

    // CASE 4 - Infer from controlled tokens
    if ( canvas.ready ) {
      let controlled = canvas.tokens.controlled;
      if (controlled.length) return this._getSpeakerFromToken({token: controlled.shift().document, alias});
    }

    // CASE 5 - Infer from impersonated Actor
    const char = game.user.character;
    if ( char ) {
      const tokens = char.getActiveTokens(false, true);
      if ( tokens.length ) return this._getSpeakerFromToken({token: tokens.shift(), alias});
      return this._getSpeakerFromActor({actor: char, alias});
    }

    // CASE 6 - From the alias and User
    return this._getSpeakerFromUser({scene, user: game.user, alias});
  }

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

  /**
   * A helper to prepare the speaker object based on a target TokenDocument
   * @param {object} [options={}]       Options which affect speaker identification
   * @param {TokenDocument} options.token        The TokenDocument of the speaker
   * @param {string} [options.alias]             The name of the speaker to display
   * @returns {object}                  The identified speaker data
   * @private
   */
  static _getSpeakerFromToken({token, alias}) {
    return {
      scene: token.parent?.id || null,
      token: token.id,
      actor: token.actor?.id || null,
      alias: alias || token.name
    };
  }

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

  /**
   * A helper to prepare the speaker object based on a target Actor
   * @param {object} [options={}]       Options which affect speaker identification
   * @param {Scene} [options.scene]             The Scene is which the speaker resides
   * @param {Actor} [options.actor]             The Actor that is speaking
   * @param {string} [options.alias]            The name of the speaker to display
   * @returns {Object}                  The identified speaker data
   * @private
   */
  static _getSpeakerFromActor({scene, actor, alias}) {
    return {
      scene: (scene || canvas.scene)?.id || null,
      actor: actor.id,
      token: null,
      alias: alias || actor.name
    };
  }
  /* -------------------------------------------- */

  /**
   * A helper to prepare the speaker object based on a target User
   * @param {object} [options={}]       Options which affect speaker identification
   * @param {Scene} [options.scene]             The Scene in which the speaker resides
   * @param {User} [options.user]               The User who is speaking
   * @param {string} [options.alias]            The name of the speaker to display
   * @returns {Object}                  The identified speaker data
   * @private
   */
  static _getSpeakerFromUser({scene, user, alias}) {
    return {
      scene: (scene || canvas.scene)?.id || null,
      actor: null,
      token: null,
      alias: alias || user.name
    };
  }

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

  /**
   * Obtain an Actor instance which represents the speaker of this message (if any)
   * @param {Object} speaker    The speaker data object
   * @returns {Actor|null}
   */
  static getSpeakerActor(speaker) {
    if ( !speaker ) return null;
    let actor = null;

    // Case 1 - Token actor
    if ( speaker.scene && speaker.token ) {
      const scene = game.scenes.get(speaker.scene);
      const token = scene ? scene.tokens.get(speaker.token) : null;
      actor = token?.actor;
    }

    // Case 2 - explicit actor
    if ( speaker.actor && !actor ) {
      actor = game.actors.get(speaker.actor);
    }
    return actor || null;
  }

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

  /**
   * Obtain a data object used to evaluate any dice rolls associated with this particular chat message
   * @returns {object}
   */
  getRollData() {
    const actor = this.constructor.getSpeakerActor(this.speaker) ?? this.author?.character;
    return actor ? actor.getRollData() : {};
  }

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

  /**
   * Given a string whisper target, return an Array of the user IDs which should be targeted for the whisper
   *
   * @param {string} name   The target name of the whisper target
   * @returns {User[]}      An array of User instances
   */
  static getWhisperRecipients(name) {

    // Whisper to groups
    if (["GM", "DM"].includes(name.toUpperCase())) {
      return game.users.filter(u => u.isGM);
    }
    else if (name.toLowerCase() === "players") {
      return game.users.players;
    }

    const lowerName = name.toLowerCase();
    const users = game.users.filter(u => u.name.toLowerCase() === lowerName);
    if ( users.length ) return users;
    const actors = game.users.filter(a => a.character && (a.character.name.toLowerCase() === lowerName));
    if ( actors.length ) return actors;

    // Otherwise, return an empty array
    return [];
  }

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

  /**
   * Render the HTML for the ChatMessage which should be added to the log
   * @returns {Promise<jQuery>}
   */
  async getHTML() {

    // Determine some metadata
    const data = this.toObject(false);
    data.content = await TextEditor.enrichHTML(this.content, {rollData: this.getRollData()});
    const isWhisper = this.whisper.length;

    // Construct message data
    const messageData = {
      message: data,
      user: game.user,
      author: this.author,
      alias: this.alias,
      cssClass: [
        this.style === CONST.CHAT_MESSAGE_STYLES.IC ? "ic" : null,
        this.style === CONST.CHAT_MESSAGE_STYLES.EMOTE ? "emote" : null,
        isWhisper ? "whisper" : null,
        this.blind ? "blind": null
      ].filterJoin(" "),
      isWhisper: this.whisper.length,
      canDelete: game.user.isGM,  // Only GM users are allowed to have the trash-bin icon in the chat log itself
      whisperTo: this.whisper.map(u => {
        let user = game.users.get(u);
        return user ? user.name : null;
      }).filterJoin(", ")
    };

    // Render message data specifically for ROLL type messages
    if ( this.isRoll ) await this._renderRollContent(messageData);

    // Define a border color
    if ( this.style === CONST.CHAT_MESSAGE_STYLES.OOC ) messageData.borderColor = this.author?.color.css;

    // Render the chat message
    let html = await renderTemplate(CONFIG.ChatMessage.template, messageData);
    html = $(html);

    // Flag expanded state of dice rolls
    if ( this._rollExpanded ) html.find(".dice-tooltip").addClass("expanded");
    Hooks.call("renderChatMessage", this, html, messageData);
    return html;
  }

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

  /**
   * Render the inner HTML content for ROLL type messages.
   * @param {object} messageData      The chat message data used to render the message HTML
   * @returns {Promise}
   * @private
   */
  async _renderRollContent(messageData) {
    const data = messageData.message;
    const renderRolls = async isPrivate => {
      let html = "";
      for ( const r of this.rolls ) {
        html += await r.render({isPrivate});
      }
      return html;
    };

    // Suppress the "to:" whisper flavor for private rolls
    if ( this.blind || this.whisper.length ) messageData.isWhisper = false;

    // Display standard Roll HTML content
    if ( this.isContentVisible ) {
      const el = document.createElement("div");
      el.innerHTML = data.content;  // Ensure the content does not already contain custom HTML
      if ( !el.childElementCount && this.rolls.length ) data.content = await this._renderRollHTML(false);
    }

    // Otherwise, show "rolled privately" messages for Roll content
    else {
      const name = this.author?.name ?? game.i18n.localize("CHAT.UnknownUser");
      data.flavor = game.i18n.format("CHAT.PrivateRollContent", {user: name});
      data.content = await renderRolls(true);
      messageData.alias = name;
    }
  }

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

  /**
   * Render HTML for the array of Roll objects included in this message.
   * @param {boolean} isPrivate   Is the chat message private?
   * @returns {Promise<string>}   The rendered HTML string
   * @private
   */
  async _renderRollHTML(isPrivate) {
    let html = "";
    for ( const roll of this.rolls ) {
      html += await roll.render({isPrivate});
    }
    return html;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;
    if ( foundry.utils.getType(data.content) === "string" ) {
      // Evaluate any immediately-evaluated inline rolls.
      const matches = data.content.matchAll(/\[\[[^/].*?]{2,3}/g);
      let content = data.content;
      for ( const [expression] of matches ) {
        content = content.replace(expression, await TextEditor.enrichHTML(expression, {
          documents: false,
          secrets: false,
          links: false,
          rolls: true,
          rollData: this.getRollData()
        }));
      }
      this.updateSource({content});
    }
    if ( this.isRoll ) {
      if ( !("sound" in data) ) this.updateSource({sound: CONFIG.sounds.dice});
      if ( options.rollMode || !(data.whisper?.length > 0) ) this.applyRollMode(options.rollMode || "roll");
    }
  }

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

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    ui.chat.postOne(this, {notify: true});
    if ( options.chatBubble && canvas.ready ) {
      game.messages.sayBubble(this);
    }
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    if ( !this.visible ) ui.chat.deleteMessage(this.id);
    else ui.chat.updateMessage(this);
    super._onUpdate(changed, options, userId);
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    ui.chat.deleteMessage(this.id, options);
    super._onDelete(options, userId);
  }

  /* -------------------------------------------- */
  /*  Importing and Exporting                     */
  /* -------------------------------------------- */

  /**
   * Export the content of the chat message into a standardized log format
   * @returns {string}
   */
  export() {
    let content = [];

    // Handle HTML content
    if ( this.content ) {
      const html = $("<article>").html(this.content.replace(/<\/div>/g, "</div>|n"));
      const text = html.length ? html.text() : this.content;
      const lines = text.replace(/\n/g, "").split("  ").filter(p => p !== "").join(" ");
      content = lines.split("|n").map(l => l.trim());
    }

    // Add Roll content
    for ( const roll of this.rolls ) {
      content.push(`${roll.formula} = ${roll.result} = ${roll.total}`);
    }

    // Author and timestamp
    const time = new Date(this.timestamp).toLocaleDateString("en-US", {
      hour: "numeric",
      minute: "numeric",
      second: "numeric"
    });

    // Format logged result
    return `[${time}] ${this.alias}\n${content.filterJoin("\n")}`;
  }
}

/**
 * @typedef {Object} CombatHistoryData
 * @property {number|null} round
 * @property {number|null} turn
 * @property {string|null} tokenId
 * @property {string|null} combatantId
 */

/**
 * The client-side Combat document which extends the common BaseCombat model.
 *
 * @extends foundry.documents.BaseCombat
 * @mixes ClientDocumentMixin
 *
 * @see {@link Combats}             The world-level collection of Combat documents
 * @see {@link Combatant}                     The Combatant embedded document which exists within a Combat document
 * @see {@link CombatConfig}                  The Combat configuration application
 */
class Combat extends ClientDocumentMixin(foundry.documents.BaseCombat) {

  /**
   * Track the sorted turn order of this combat encounter
   * @type {Combatant[]}
   */
  turns = this.turns || [];

  /**
   * Record the current round, turn, and tokenId to understand changes in the encounter state
   * @type {CombatHistoryData}
   */
  current = this._getCurrentState();

  /**
   * Track the previous round, turn, and tokenId to understand changes in the encounter state
   * @type {CombatHistoryData}
   */
  previous = undefined;

  /**
   * The configuration setting used to record Combat preferences
   * @type {string}
   */
  static CONFIG_SETTING = "combatTrackerConfig";

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

  /**
   * Get the Combatant who has the current turn.
   * @type {Combatant}
   */
  get combatant() {
    return this.turns[this.turn];
  }

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

  /**
   * Get the Combatant who has the next turn.
   * @type {Combatant}
   */
  get nextCombatant() {
    if ( this.turn === this.turns.length - 1 ) return this.turns[0];
    return this.turns[this.turn + 1];
  }

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

  /**
   * Return the object of settings which modify the Combat Tracker behavior
   * @type {object}
   */
  get settings() {
    return CombatEncounters.settings;
  }

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

  /**
   * Has this combat encounter been started?
   * @type {boolean}
   */
  get started() {
    return this.round > 0;
  }

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

  /** @inheritdoc */
  get visible() {
    return true;
  }

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

  /**
   * Is this combat active in the current scene?
   * @type {boolean}
   */
  get isActive() {
    if ( !this.scene ) return this.active;
    return this.scene.isView && this.active;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Set the current Combat encounter as active within the Scene.
   * Deactivate all other Combat encounters within the viewed Scene and set this one as active
   * @param {object} [options] Additional context to customize the update workflow
   * @returns {Promise<Combat>}
   */
  async activate(options) {
    const updates = this.collection.reduce((arr, c) => {
      if ( c.isActive ) arr.push({_id: c.id, active: false});
      return arr;
    }, []);
    updates.push({_id: this.id, active: true});
    return this.constructor.updateDocuments(updates, options);
  }

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

  /** @override */
  prepareDerivedData() {
    if ( this.combatants.size && !this.turns?.length ) this.setupTurns();
  }

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

  /**
   * Get a Combatant using its Token id
   * @param {string|TokenDocument} token    A Token ID or a TokenDocument instance
   * @returns {Combatant[]}                 An array of Combatants which represent the Token
   */
  getCombatantsByToken(token) {
    const tokenId = token instanceof TokenDocument ? token.id : token;
    return this.combatants.filter(c => c.tokenId === tokenId);
  }

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

  /**
   * Get a Combatant that represents the given Actor or Actor ID.
   * @param {string|Actor} actor              An Actor ID or an Actor instance
   * @returns {Combatant[]}
   */
  getCombatantsByActor(actor) {
    const isActor = actor instanceof Actor;
    if ( isActor && actor.isToken ) return this.getCombatantsByToken(actor.token);
    const actorId = isActor ? actor.id : actor;
    return this.combatants.filter(c => c.actorId === actorId);
  }

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

  /**
   * Begin the combat encounter, advancing to round 1 and turn 1
   * @returns {Promise<Combat>}
   */
  async startCombat() {
    this._playCombatSound("startEncounter");
    const updateData = {round: 1, turn: 0};
    Hooks.callAll("combatStart", this, updateData);
    return this.update(updateData);
  }

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

  /**
   * Advance the combat to the next round
   * @returns {Promise<Combat>}
   */
  async nextRound() {
    let turn = this.turn === null ? null : 0; // Preserve the fact that it's no-one's turn currently.
    if ( this.settings.skipDefeated && (turn !== null) ) {
      turn = this.turns.findIndex(t => !t.isDefeated);
      if (turn === -1) {
        ui.notifications.warn("COMBAT.NoneRemaining", {localize: true});
        turn = 0;
      }
    }
    let advanceTime = Math.max(this.turns.length - this.turn, 0) * CONFIG.time.turnTime;
    advanceTime += CONFIG.time.roundTime;
    let nextRound = this.round + 1;

    // Update the document, passing data through a hook first
    const updateData = {round: nextRound, turn};
    const updateOptions = {direction: 1, worldTime: {delta: advanceTime}};
    Hooks.callAll("combatRound", this, updateData, updateOptions);
    return this.update(updateData, updateOptions);
  }

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

  /**
   * Rewind the combat to the previous round
   * @returns {Promise<Combat>}
   */
  async previousRound() {
    let turn = ( this.round === 0 ) ? 0 : Math.max(this.turns.length - 1, 0);
    if ( this.turn === null ) turn = null;
    let round = Math.max(this.round - 1, 0);
    if ( round === 0 ) turn = null;
    let advanceTime = -1 * (this.turn || 0) * CONFIG.time.turnTime;
    if ( round > 0 ) advanceTime -= CONFIG.time.roundTime;

    // Update the document, passing data through a hook first
    const updateData = {round, turn};
    const updateOptions = {direction: -1, worldTime: {delta: advanceTime}};
    Hooks.callAll("combatRound", this, updateData, updateOptions);
    return this.update(updateData, updateOptions);
  }

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

  /**
   * Advance the combat to the next turn
   * @returns {Promise<Combat>}
   */
  async nextTurn() {
    let turn = this.turn ?? -1;
    let skip = this.settings.skipDefeated;

    // Determine the next turn number
    let next = null;
    if ( skip ) {
      for ( let [i, t] of this.turns.entries() ) {
        if ( i <= turn ) continue;
        if ( t.isDefeated ) continue;
        next = i;
        break;
      }
    }
    else next = turn + 1;

    // Maybe advance to the next round
    let round = this.round;
    if ( (this.round === 0) || (next === null) || (next >= this.turns.length) ) {
      return this.nextRound();
    }

    // Update the document, passing data through a hook first
    const updateData = {round, turn: next};
    const updateOptions = {direction: 1, worldTime: {delta: CONFIG.time.turnTime}};
    Hooks.callAll("combatTurn", this, updateData, updateOptions);
    return this.update(updateData, updateOptions);
  }

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

  /**
   * Rewind the combat to the previous turn
   * @returns {Promise<Combat>}
   */
  async previousTurn() {
    if ( (this.turn === 0) && (this.round === 0) ) return this;
    else if ( (this.turn <= 0) && (this.turn !== null) ) return this.previousRound();
    let previousTurn = (this.turn ?? this.turns.length) - 1;

    // Update the document, passing data through a hook first
    const updateData = {round: this.round, turn: previousTurn};
    const updateOptions = {direction: -1, worldTime: {delta: -1 * CONFIG.time.turnTime}};
    Hooks.callAll("combatTurn", this, updateData, updateOptions);
    return this.update(updateData, updateOptions);
  }

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

  /**
   * Display a dialog querying the GM whether they wish to end the combat encounter and empty the tracker
   * @returns {Promise<Combat>}
   */
  async endCombat() {
    return Dialog.confirm({
      title: game.i18n.localize("COMBAT.EndTitle"),
      content: `<p>${game.i18n.localize("COMBAT.EndConfirmation")}</p>`,
      yes: () => this.delete()
    });
  }

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

  /**
   * Toggle whether this combat is linked to the scene or globally available.
   * @returns {Promise<Combat>}
   */
  async toggleSceneLink() {
    const scene = this.scene ? null : (game.scenes.current?.id || null);
    if ( (scene !== null) && this.combatants.some(c => c.sceneId && (c.sceneId !== scene)) ) {
      ui.notifications.error("COMBAT.CannotLinkToScene", {localize: true});
      return this;
    }
    return this.update({scene});
  }

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

  /**
   * Reset all combatant initiative scores, setting the turn back to zero
   * @returns {Promise<Combat>}
   */
  async resetAll() {
    for ( let c of this.combatants ) {
      c.updateSource({initiative: null});
    }
    return this.update({turn: this.started ? 0 : null, combatants: this.combatants.toObject()}, {diff: false});
  }

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

  /**
   * Roll initiative for one or multiple Combatants within the Combat document
   * @param {string|string[]} ids     A Combatant id or Array of ids for which to roll
   * @param {object} [options={}]     Additional options which modify how initiative rolls are created or presented.
   * @param {string|null} [options.formula]         A non-default initiative formula to roll. Otherwise, the system
   *                                                default is used.
   * @param {boolean} [options.updateTurn=true]     Update the Combat turn after adding new initiative scores to
   *                                                keep the turn on the same Combatant.
   * @param {object} [options.messageOptions={}]    Additional options with which to customize created Chat Messages
   * @returns {Promise<Combat>}       A promise which resolves to the updated Combat document once updates are complete.
   */
  async rollInitiative(ids, {formula=null, updateTurn=true, messageOptions={}}={}) {

    // Structure input data
    ids = typeof ids === "string" ? [ids] : ids;
    const currentId = this.combatant?.id;
    const chatRollMode = game.settings.get("core", "rollMode");

    // Iterate over Combatants, performing an initiative roll for each
    const updates = [];
    const messages = [];
    for ( let [i, id] of ids.entries() ) {

      // Get Combatant data (non-strictly)
      const combatant = this.combatants.get(id);
      if ( !combatant?.isOwner ) continue;

      // Produce an initiative roll for the Combatant
      const roll = combatant.getInitiativeRoll(formula);
      await roll.evaluate();
      updates.push({_id: id, initiative: roll.total});

      // Construct chat message data
      let messageData = foundry.utils.mergeObject({
        speaker: ChatMessage.getSpeaker({
          actor: combatant.actor,
          token: combatant.token,
          alias: combatant.name
        }),
        flavor: game.i18n.format("COMBAT.RollsInitiative", {name: combatant.name}),
        flags: {"core.initiativeRoll": true}
      }, messageOptions);
      const chatData = await roll.toMessage(messageData, {create: false});

      // If the combatant is hidden, use a private roll unless an alternative rollMode was explicitly requested
      chatData.rollMode = "rollMode" in messageOptions ? messageOptions.rollMode
        : (combatant.hidden ? CONST.DICE_ROLL_MODES.PRIVATE : chatRollMode );

      // Play 1 sound for the whole rolled set
      if ( i > 0 ) chatData.sound = null;
      messages.push(chatData);
    }
    if ( !updates.length ) return this;

    // Update multiple combatants
    await this.updateEmbeddedDocuments("Combatant", updates);

    // Ensure the turn order remains with the same combatant
    if ( updateTurn && currentId ) {
      await this.update({turn: this.turns.findIndex(t => t.id === currentId)});
    }

    // Create multiple chat messages
    await ChatMessage.implementation.create(messages);
    return this;
  }

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

  /**
   * Roll initiative for all combatants which have not already rolled
   * @param {object} [options={}]   Additional options forwarded to the Combat.rollInitiative method
   */
  async rollAll(options) {
    const ids = this.combatants.reduce((ids, c) => {
      if ( c.isOwner && (c.initiative === null) ) ids.push(c.id);
      return ids;
    }, []);
    return this.rollInitiative(ids, options);
  }

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

  /**
   * Roll initiative for all non-player actors who have not already rolled
   * @param {object} [options={}]   Additional options forwarded to the Combat.rollInitiative method
   */
  async rollNPC(options={}) {
    const ids = this.combatants.reduce((ids, c) => {
      if ( c.isOwner && c.isNPC && (c.initiative === null) ) ids.push(c.id);
      return ids;
    }, []);
    return this.rollInitiative(ids, options);
  }

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

  /**
   * Assign initiative for a single Combatant within the Combat encounter.
   * Update the Combat turn order to maintain the same combatant as the current turn.
   * @param {string} id         The combatant ID for which to set initiative
   * @param {number} value      A specific initiative value to set
   */
  async setInitiative(id, value) {
    const combatant = this.combatants.get(id, {strict: true});
    await combatant.update({initiative: value});
  }

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

  /**
   * Return the Array of combatants sorted into initiative order, breaking ties alphabetically by name.
   * @returns {Combatant[]}
   */
  setupTurns() {
    this.turns ||= [];

    // Determine the turn order and the current turn
    const turns = this.combatants.contents.sort(this._sortCombatants);
    if ( this.turn !== null) this.turn = Math.clamp(this.turn, 0, turns.length-1);

    // Update state tracking
    let c = turns[this.turn];
    this.current = this._getCurrentState(c);

    // One-time initialization of the previous state
    if ( !this.previous ) this.previous = this.current;

    // Return the array of prepared turns
    return this.turns = turns;
  }

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

  /**
   * Debounce changes to the composition of the Combat encounter to de-duplicate multiple concurrent Combatant changes.
   * If this is the currently viewed encounter, re-render the CombatTracker application.
   * @type {Function}
   */
  debounceSetup = foundry.utils.debounce(() => {
    this.current.round = this.round;
    this.current.turn = this.turn;
    this.setupTurns();
    if ( ui.combat.viewed === this ) ui.combat.render();
  }, 50);

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

  /**
   * Update active effect durations for all actors present in this Combat encounter.
   */
  updateCombatantActors() {
    for ( const combatant of this.combatants ) combatant.actor?.render(false, {renderContext: "updateCombat"});
  }

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

  /**
   * Loads the registered Combat Theme (if any) and plays the requested type of sound.
   * If multiple exist for that type, one is chosen at random.
   * @param {string} announcement     The announcement that should be played: "startEncounter", "nextUp", or "yourTurn".
   * @protected
   */
  _playCombatSound(announcement) {
    if ( !CONST.COMBAT_ANNOUNCEMENTS.includes(announcement) ) {
      throw new Error(`"${announcement}" is not a valid Combat announcement type`);
    }
    const theme = CONFIG.Combat.sounds[game.settings.get("core", "combatTheme")];
    if ( !theme || theme === "none" ) return;
    const sounds = theme[announcement];
    if ( !sounds ) return;
    const src = sounds[Math.floor(Math.random() * sounds.length)];
    game.audio.play(src, {context: game.audio.interface});
  }

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

  /**
   * Define how the array of Combatants is sorted in the displayed list of the tracker.
   * This method can be overridden by a system or module which needs to display combatants in an alternative order.
   * The default sorting rules sort in descending order of initiative using combatant IDs for tiebreakers.
   * @param {Combatant} a     Some combatant
   * @param {Combatant} b     Some other combatant
   * @protected
   */
  _sortCombatants(a, b) {
    const ia = Number.isNumeric(a.initiative) ? a.initiative : -Infinity;
    const ib = Number.isNumeric(b.initiative) ? b.initiative : -Infinity;
    return (ib - ia) || (a.id > b.id ? 1 : -1);
  }

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

  /**
   * Refresh the Token HUD under certain circumstances.
   * @param {Combatant[]} documents  A list of Combatant documents that were added or removed.
   * @protected
   */
  _refreshTokenHUD(documents) {
    if ( documents.some(doc => doc.token?.object?.hasActiveHUD) ) canvas.tokens.hud.render();
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( !this.collection.viewed && this.collection.combats.includes(this) ) {
      ui.combat.initialize({combat: this, render: false});
    }
    this._manageTurnEvents();
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    const priorState = foundry.utils.deepClone(this.current);
    if ( !this.previous ) this.previous = priorState; // Just in case

    // Determine the new turn order
    if ( "combatants" in changed ) this.setupTurns(); // Update all combatants
    else this.current = this._getCurrentState();      // Update turn or round

    // Record the prior state and manage turn events
    const stateChanged = this.#recordPreviousState(priorState);
    if ( stateChanged && (options.turnEvents !== false) ) this._manageTurnEvents();

    // Render applications for Actors involved in the Combat
    this.updateCombatantActors();

    // Render the CombatTracker sidebar
    if ( (changed.active === true) && this.isActive ) ui.combat.initialize({combat: this});
    else if ( "scene" in changed ) ui.combat.initialize();

    // Trigger combat sound cues in the active encounter
    if ( this.active && this.started && priorState.round ) {
      const play = c => c && (game.user.isGM ? !c.hasPlayerOwner : c.isOwner);
      if ( play(this.combatant) ) this._playCombatSound("yourTurn");
      else if ( play(this.nextCombatant) ) this._playCombatSound("nextUp");
    }
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( this.collection.viewed === this ) ui.combat.initialize();
    if ( userId === game.userId ) this.collection.viewed?.activate();
  }

  /* -------------------------------------------- */
  /*  Combatant Management Workflows              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
    super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
    this.#onModifyCombatants(parent, documents, options);
  }

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

  /** @inheritDoc */
  _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
    this.#onModifyCombatants(parent, documents, options);
  }

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

  /** @inheritDoc */
  _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
    this.#onModifyCombatants(parent, documents, options);
  }

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

  /**
   * Shared actions taken when Combatants are modified within this Combat document.
   * @param {Document} parent         The direct parent of the created Documents, may be this Document or a child
   * @param {Document[]} documents    The array of created Documents
   * @param {object} options          Options which modified the operation
   */
  #onModifyCombatants(parent, documents, options) {
    const {combatTurn, turnEvents, render} = options;
    if ( parent === this ) this._refreshTokenHUD(documents);
    const priorState = foundry.utils.deepClone(this.current);
    if ( typeof combatTurn === "number" ) this.updateSource({turn: combatTurn});
    this.setupTurns();
    const turnChange = this.#recordPreviousState(priorState);
    if ( turnChange && (turnEvents !== false) ) this._manageTurnEvents();
    if ( (ui.combat.viewed === parent) && (render !== false) ) ui.combat.render();
  }

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

  /**
   * Get the current history state of the Combat encounter.
   * @param {Combatant} [combatant]       The new active combatant
   * @returns {CombatHistoryData}
   * @protected
   */
  _getCurrentState(combatant) {
    combatant ||= this.combatant;
    return {
      round: this.round,
      turn: this.turn ?? null,
      combatantId: combatant?.id || null,
      tokenId: combatant?.tokenId || null
    };
  }

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

  /**
   * Update the previous turn data.
   * Compare the state with the new current state. Only update the previous state if there is a difference.
   * @param {CombatHistoryData} priorState    A cloned copy of the current history state before changes
   * @returns {boolean}                       Has the combat round or current combatant changed?
   */
  #recordPreviousState(priorState) {
    const {round, combatantId} = this.current;
    const turnChange = (combatantId !== priorState.combatantId) || (round !== priorState.round);
    Object.assign(this.previous, priorState);
    return turnChange;
  }

  /* -------------------------------------------- */
  /*  Turn Events                                 */
  /* -------------------------------------------- */

  /**
   * Manage the execution of Combat lifecycle events.
   * This method orchestrates the execution of four events in the following order, as applicable:
   * 1. End Turn
   * 2. End Round
   * 3. Begin Round
   * 4. Begin Turn
   * Each lifecycle event is an async method, and each is awaited before proceeding.
   * @returns {Promise<void>}
   * @protected
   */
  async _manageTurnEvents() {
    if ( !this.started ) return;

    // Gamemaster handling only
    if ( game.users.activeGM?.isSelf ) {
      const advanceRound = this.current.round > (this.previous.round ?? -1);
      const advanceTurn = advanceRound || (this.current.turn > (this.previous.turn ?? -1));
      const changeCombatant = this.current.combatantId !== this.previous.combatantId;
      if ( !(advanceTurn || advanceRound || changeCombatant) ) return;

      // Conclude the prior Combatant turn
      const prior = this.combatants.get(this.previous.combatantId);
      if ( (advanceTurn || changeCombatant) && prior ) await this._onEndTurn(prior);

      // Conclude the prior round
      if ( advanceRound && this.previous.round ) await this._onEndRound();

      // Begin the new round
      if ( advanceRound ) await this._onStartRound();

      // Begin a new Combatant turn
      const next = this.combatant;
      if ( (advanceTurn || changeCombatant) && next ) await this._onStartTurn(this.combatant);
    }

    // Hooks handled by all clients
    Hooks.callAll("combatTurnChange", this, this.previous, this.current);
  }

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

  /**
   * A workflow that occurs at the end of each Combat Turn.
   * This workflow occurs after the Combat document update, prior round information exists in this.previous.
   * This can be overridden to implement system-specific combat tracking behaviors.
   * This method only executes for one designated GM user. If no GM users are present this method will not be called.
   * @param {Combatant} combatant     The Combatant whose turn just ended
   * @returns {Promise<void>}
   * @protected
   */
  async _onEndTurn(combatant) {
    if ( CONFIG.debug.combat ) {
      console.debug(`${vtt} | Combat End Turn: ${this.combatants.get(this.previous.combatantId).name}`);
    }
    // noinspection ES6MissingAwait
    this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_TURN_END, [combatant]);
  }

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

  /**
   * A workflow that occurs at the end of each Combat Round.
   * This workflow occurs after the Combat document update, prior round information exists in this.previous.
   * This can be overridden to implement system-specific combat tracking behaviors.
   * This method only executes for one designated GM user. If no GM users are present this method will not be called.
   * @returns {Promise<void>}
   * @protected
   */
  async _onEndRound() {
    if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat End Round: ${this.previous.round}`);
    // noinspection ES6MissingAwait
    this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_ROUND_END, this.combatants);
  }

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

  /**
   * A workflow that occurs at the start of each Combat Round.
   * This workflow occurs after the Combat document update, new round information exists in this.current.
   * This can be overridden to implement system-specific combat tracking behaviors.
   * This method only executes for one designated GM user. If no GM users are present this method will not be called.
   * @returns {Promise<void>}
   * @protected
   */
  async _onStartRound() {
    if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat Start Round: ${this.round}`);
    // noinspection ES6MissingAwait
    this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_ROUND_START, this.combatants);
  }

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

  /**
   * A workflow that occurs at the start of each Combat Turn.
   * This workflow occurs after the Combat document update, new turn information exists in this.current.
   * This can be overridden to implement system-specific combat tracking behaviors.
   * This method only executes for one designated GM user. If no GM users are present this method will not be called.
   * @param {Combatant} combatant     The Combatant whose turn just started
   * @returns {Promise<void>}
   * @protected
   */
  async _onStartTurn(combatant) {
    if ( CONFIG.debug.combat ) console.debug(`${vtt} | Combat Start Turn: ${this.combatant.name}`);
    // noinspection ES6MissingAwait
    this.#triggerRegionEvents(CONST.REGION_EVENTS.TOKEN_TURN_START, [combatant]);
  }

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

  /**
   * Trigger Region events for Combat events.
   * @param {string} eventName                  The event name
   * @param {Iterable<Combatant>} combatants    The combatants to trigger the event for
   * @returns {Promise<void>}
   */
  async #triggerRegionEvents(eventName, combatants) {
    const promises = [];
    for ( const combatant of combatants ) {
      const token = combatant.token;
      if ( !token ) continue;
      for ( const region of token.regions ) {
        promises.push(region._triggerEvent(eventName, {token, combatant}));
      }
    }
    await Promise.allSettled(promises);
  }

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

  /**
   * @deprecated since v11
   * @ignore
   */
  updateEffectDurations() {
    const msg = "Combat#updateEffectDurations is renamed to Combat#updateCombatantActors";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return this.updateCombatantActors();
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getCombatantByActor(actor) {
    const combatants = this.getCombatantsByActor(actor);
    return combatants?.[0] || null;
  }

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

  /**
   * @deprecated since v12
   * @ignore
   */
  getCombatantByToken(token) {
    const combatants = this.getCombatantsByToken(token);
    return combatants?.[0] || null;
  }
}

/**
 * The client-side Combatant document which extends the common BaseCombatant model.
 *
 * @extends foundry.documents.BaseCombatant
 * @mixes ClientDocumentMixin
 *
 * @see {@link Combat}                  The Combat document which contains Combatant embedded documents
 * @see {@link CombatantConfig}         The application which configures a Combatant.
 */
class Combatant extends ClientDocumentMixin(foundry.documents.BaseCombatant) {

  /**
   * The token video source image (if any)
   * @type {string|null}
   * @internal
   */
  _videoSrc = null;

  /**
   * The current value of the special tracked resource which pertains to this Combatant
   * @type {object|null}
   */
  resource = null;

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

  /**
   * A convenience alias of Combatant#parent which is more semantically intuitive
   * @type {Combat|null}
   */
  get combat() {
    return this.parent;
  }

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

  /**
   * This is treated as a non-player combatant if it has no associated actor and no player users who can control it
   * @type {boolean}
   */
  get isNPC() {
    return !this.actor || !this.hasPlayerOwner;
  }

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

  /**
   * Eschew `ClientDocument`'s redirection to `Combat#permission` in favor of special ownership determination.
   * @override
   */
  get permission() {
    if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return this.getUserLevel(game.user);
  }

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

  /** @override */
  get visible() {
    return this.isOwner || !this.hidden;
  }

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

  /**
   * A reference to the Actor document which this Combatant represents, if any
   * @type {Actor|null}
   */
  get actor() {
    if ( this.token ) return this.token.actor;
    return game.actors.get(this.actorId) || null;
  }

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

  /**
   * A reference to the Token document which this Combatant represents, if any
   * @type {TokenDocument|null}
   */
  get token() {
    const scene = this.sceneId ? game.scenes.get(this.sceneId) : this.parent?.scene;
    return scene?.tokens.get(this.tokenId) || null;
  }

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

  /**
   * An array of non-Gamemaster Users who have ownership of this Combatant.
   * @type {User[]}
   */
  get players() {
    return game.users.filter(u => !u.isGM && this.testUserPermission(u, "OWNER"));
  }

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

  /**
   * Has this combatant been marked as defeated?
   * @type {boolean}
   */
  get isDefeated() {
    return this.defeated || !!this.actor?.statuses.has(CONFIG.specialStatusEffects.DEFEATED);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  testUserPermission(user, permission, {exact=false}={}) {
    if ( user.isGM ) return true;
    return this.actor?.canUserModify(user, "update") || false;
  }

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

  /**
   * Get a Roll object which represents the initiative roll for this Combatant.
   * @param {string} formula        An explicit Roll formula to use for the combatant.
   * @returns {Roll}                The unevaluated Roll instance to use for the combatant.
   */
  getInitiativeRoll(formula) {
    formula = formula || this._getInitiativeFormula();
    const rollData = this.actor?.getRollData() || {};
    return Roll.create(formula, rollData);
  }

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

  /**
   * Roll initiative for this particular combatant.
   * @param {string} [formula]      A dice formula which overrides the default for this Combatant.
   * @returns {Promise<Combatant>}  The updated Combatant.
   */
  async rollInitiative(formula) {
    const roll = this.getInitiativeRoll(formula);
    await roll.evaluate();
    return this.update({initiative: roll.total});
  }

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

  /** @override */
  prepareDerivedData() {
    // Check for video source and save it if present
    this._videoSrc = VideoHelper.hasVideoExtension(this.token?.texture.src) ? this.token.texture.src : null;

    // Assign image for combatant (undefined if the token src image is a video)
    this.img ||= (this._videoSrc ? undefined : (this.token?.texture.src || this.actor?.img));
    this.name ||= this.token?.name || this.actor?.name || game.i18n.localize("COMBAT.UnknownCombatant");

    this.updateResource();
  }

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

  /**
   * Update the value of the tracked resource for this Combatant.
   * @returns {null|object}
   */
  updateResource() {
    if ( !this.actor || !this.combat ) return this.resource = null;
    return this.resource = foundry.utils.getProperty(this.actor.system, this.parent.settings.resource) || null;
  }

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

  /**
   * Acquire the default dice formula which should be used to roll initiative for this combatant.
   * Modules or systems could choose to override or extend this to accommodate special situations.
   * @returns {string}               The initiative formula to use for this combatant.
   * @protected
   */
  _getInitiativeFormula() {
    return String(CONFIG.Combat.initiative.formula || game.system.initiative);
  }

  /* -------------------------------------------- */
  /*  Database Lifecycle Events                   */
  /* -------------------------------------------- */

  /** @override */
  static async _preCreateOperation(documents, operation, _user) {
    const combatant = operation.parent?.combatant;
    if ( !combatant ) return;
    const combat = operation.parent.clone();
    combat.updateSource({combatants: documents.map(d => d.toObject())});
    combat.setupTurns();
    operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0);
  }

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

  /** @override */
  static async _preUpdateOperation(_documents, operation, _user) {
    const combatant = operation.parent?.combatant;
    if ( !combatant ) return;
    const combat = operation.parent.clone();
    combat.updateSource({combatants: operation.updates});
    combat.setupTurns();
    if ( operation.turnEvents !== false ) {
      operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0);
    }
  }

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

  /** @override */
  static async _preDeleteOperation(_documents, operation, _user) {
    const combatant = operation.parent?.combatant;
    if ( !combatant ) return;

    // Simulate new turns
    const combat = operation.parent.clone();
    for ( const id of operation.ids ) combat.combatants.delete(id);
    combat.setupTurns();

    // If the current combatant was deleted
    if ( operation.ids.includes(combatant?.id) ) {
      const {prevSurvivor, nextSurvivor} = operation.parent.turns.reduce((obj, t, i) => {
        let valid = !operation.ids.includes(t.id);
        if ( combat.settings.skipDefeated ) valid &&= !t.isDefeated;
        if ( !valid ) return obj;
        if ( i < this.turn ) obj.prevSurvivor = t;
        if ( !obj.nextSurvivor && (i >= this.turn) ) obj.nextSurvivor = t;
        return obj;
      }, {});
      const survivor = nextSurvivor || prevSurvivor;
      if ( survivor ) operation.combatTurn = combat.turns.findIndex(t => t.id === survivor.id);
    }

    // Otherwise maintain the same combatant turn
    else operation.combatTurn = Math.max(combat.turns.findIndex(t => t.id === combatant.id), 0);
  }
}

/**
 * The client-side Drawing document which extends the common BaseDrawing model.
 *
 * @extends foundry.documents.BaseDrawing
 * @mixes ClientDocumentMixin
 *
 * @see {@link Scene}               The Scene document type which contains Drawing embedded documents
 * @see {@link DrawingConfig}       The Drawing configuration application
 */
class DrawingDocument extends CanvasDocumentMixin(foundry.documents.BaseDrawing) {

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

  /**
   * Is the current User the author of this drawing?
   * @type {boolean}
   */
  get isAuthor() {
    return game.user === this.author;
  }
}

/**
 * The client-side FogExploration document which extends the common BaseFogExploration model.
 * @extends foundry.documents.BaseFogExploration
 * @mixes ClientDocumentMixin
 */
class FogExploration extends ClientDocumentMixin(foundry.documents.BaseFogExploration) {
  /**
   * Obtain the fog of war exploration progress for a specific Scene and User.
   * @param {object} [query]        Parameters for which FogExploration document is retrieved
   * @param {string} [query.scene]    A certain Scene ID
   * @param {string} [query.user]     A certain User ID
   * @param {object} [options={}]   Additional options passed to DatabaseBackend#get
   * @returns {Promise<FogExploration|null>}
   */
  static async load({scene, user}={}, options={}) {
    const collection = game.collections.get("FogExploration");
    const sceneId = (scene || canvas.scene)?.id || null;
    const userId = (user || game.user)?.id;
    if ( !sceneId || !userId ) return null;
    if ( !(game.user.isGM || (userId === game.user.id)) ) {
      throw new Error("You do not have permission to access the FogExploration object of another user");
    }

    // Return cached exploration
    let exploration = collection.find(x => (x.user === userId) && (x.scene === sceneId));
    if ( exploration ) return exploration;

    // Return persisted exploration
    const query = {scene: sceneId, user: userId};
    const response = await this.database.get(this, {query, ...options});
    exploration = response.length ? response.shift() : null;
    if ( exploration ) collection.set(exploration.id, exploration);
    return exploration;
  }

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

  /**
   * Transform the explored base64 data into a PIXI.Texture object
   * @returns {PIXI.Texture|null}
   */
  getTexture() {
    if ( !this.explored ) return null;
    const bt = new PIXI.BaseTexture(this.explored);
    return new PIXI.Texture(bt);
  }

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

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load();
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load();
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( (options.loadFog !== false) && (this.user === game.user) && (this.scene === canvas.scene) ) canvas.fog.load();
  }

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

  /**
   * @deprecated since v11
   * @ignore
   */
  explore(source, force=false) {
    const msg = "explore is obsolete and always returns true. The fog exploration does not record position anymore.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return true;
  }

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

  /** @inheritDoc */
  static get(...args) {
    if ( typeof args[0] === "object" ) {
      foundry.utils.logCompatibilityWarning("You are calling FogExploration.get by passing an object. This means you"
        + " are probably trying to load Fog of War exploration data, an operation which has been renamed to"
        + " FogExploration.load", {since: 12, until: 14});
      return this.load(...args);
    }
    return super.get(...args);
  }
}

/**
 * The client-side Folder document which extends the common BaseFolder model.
 * @extends foundry.documents.BaseFolder
 * @mixes ClientDocumentMixin
 *
 * @see {@link Folders}                     The world-level collection of Folder documents
 * @see {@link FolderConfig}                The Folder configuration application
 */
class Folder extends ClientDocumentMixin(foundry.documents.BaseFolder) {

  /**
   * The depth of this folder in its sidebar tree
   * @type {number}
   */
  depth;

  /**
   * An array of other Folders which are the displayed children of this one. This differs from the results of
   * {@link Folder.getSubfolders} because reports the subset of child folders which  are displayed to the current User
   * in the UI.
   * @type {Folder[]}
   */
  children;

  /**
   * Return whether the folder is displayed in the sidebar to the current User.
   * @type {boolean}
   */
  displayed = false;

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

  /**
   * The array of the Document instances which are contained within this Folder,
   * unless it's a Folder inside a Compendium pack, in which case it's the array
   * of objects inside the index of the pack that are contained in this Folder.
   * @type {(ClientDocument|object)[]}
   */
  get contents() {
    if ( this.#contents ) return this.#contents;
    if ( this.pack ) return game.packs.get(this.pack).index.filter(d => d.folder === this.id );
    return this.documentCollection?.filter(d => d.folder === this) ?? [];
  }

  set contents(value) {
    this.#contents = value;
  }

  #contents;

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

  /**
   * The reference to the Document type which is contained within this Folder.
   * @type {Function}
   */
  get documentClass() {
    return CONFIG[this.type].documentClass;
  }

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

  /**
   * The reference to the WorldCollection instance which provides Documents to this Folder,
   * unless it's a Folder inside a Compendium pack, in which case it's the index of the pack.
   * A world Folder containing CompendiumCollections will have neither.
   * @type {WorldCollection|Collection|undefined}
   */
  get documentCollection() {
    if ( this.pack ) return game.packs.get(this.pack).index;
    return game.collections.get(this.type);
  }

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

  /**
   * Return whether the folder is currently expanded within the sidebar interface.
   * @type {boolean}
   */
  get expanded() {
    return game.folders._expanded[this.uuid] || false;
  }

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

  /**
   * Return the list of ancestors of this folder, starting with the parent.
   * @type {Folder[]}
   */
  get ancestors() {
    if ( !this.folder ) return [];
    return [this.folder, ...this.folder.ancestors];
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {

    // If the folder would be created past the maximum depth, throw an error
    if ( data.folder ) {
      const collection = data.pack ? game.packs.get(data.pack).folders : game.folders;
      const parent = collection.get(data.folder);
      if ( !parent ) return;
      const maxDepth = data.pack ? (CONST.FOLDER_MAX_DEPTH - 1) : CONST.FOLDER_MAX_DEPTH;
      if ( (parent.ancestors.length + 1) >= maxDepth ) throw new Error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: maxDepth}));
    }

    return super._preCreate(data, options, user);
  }

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

  /** @override */
  static async createDialog(data={}, options={}) {
    const folder = new Folder.implementation(foundry.utils.mergeObject({
      name: Folder.implementation.defaultName({pack: options.pack}),
      sorting: "a"
    }, data), { pack: options.pack });
    return new Promise(resolve => {
      options.resolve = resolve;
      new FolderConfig(folder, options).render(true);
    });
  }

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

  /**
   * Export all Documents contained in this Folder to a given Compendium pack.
   * Optionally update existing Documents within the Pack by name, otherwise append all new entries.
   * @param {CompendiumCollection} pack       A Compendium pack to which the documents will be exported
   * @param {object} [options]                Additional options which customize how content is exported.
   *                                          See {@link ClientDocumentMixin#toCompendium}
   * @param {boolean} [options.updateByName=false]    Update existing entries in the Compendium pack, matching by name
   * @param {boolean} [options.keepId=false]          Retain the original _id attribute when updating an entity
   * @param {boolean} [options.keepFolders=false]     Retain the existing Folder structure
   * @param {string} [options.folder]                 A target folder id to which the documents will be exported
   * @returns {Promise<CompendiumCollection>}  The updated Compendium Collection instance
   */
  async exportToCompendium(pack, options={}) {
    const updateByName = options.updateByName ?? false;
    const index = await pack.getIndex();
    ui.notifications.info(game.i18n.format("FOLDER.Exporting", {
      type: game.i18n.localize(getDocumentClass(this.type).metadata.labelPlural),
      compendium: pack.collection
    }));
    options.folder ||= null;

    // Classify creations and updates
    const foldersToCreate = [];
    const foldersToUpdate = [];
    const documentsToCreate = [];
    const documentsToUpdate = [];

    // Ensure we do not overflow maximum allowed folder depth
    const originDepth = this.ancestors.length;
    const targetDepth = options.folder ? ((pack.folders.get(options.folder)?.ancestors.length ?? 0) + 1) : 0;

    /**
     * Recursively extract the contents and subfolders of a Folder into the Pack
     * @param {Folder} folder       The Folder to extract
     * @param {number} [_depth]     An internal recursive depth tracker
     * @private
     */
    const _extractFolder = async (folder, _depth=0) => {
      const folderData = folder.toCompendium(pack, {...options, clearSort: false, keepId: true});

      if ( options.keepFolders ) {
        // Ensure that the exported folder is within the maximum allowed folder depth
        const currentDepth = _depth + targetDepth - originDepth;
        const exceedsDepth = currentDepth > pack.maxFolderDepth;
        if ( exceedsDepth ) {
          throw new Error(`Folder "${folder.name}" exceeds maximum allowed folder depth of ${pack.maxFolderDepth}`);
        }

        // Re-parent child folders into the target folder or into the compendium root
        if ( folderData.folder === this.id ) folderData.folder = options.folder;

        // Classify folder data for creation or update
        if ( folder !== this ) {
          const existing = updateByName ? pack.folders.find(f => f.name === folder.name) : pack.folders.get(folder.id);
          if ( existing ) {
            folderData._id = existing._id;
            foldersToUpdate.push(folderData);
          }
          else foldersToCreate.push(folderData);
        }
      }

      // Iterate over Documents in the Folder, preparing each for export
      for ( let doc of folder.contents ) {
        const data = doc.toCompendium(pack, options);

        // Re-parent immediate child documents into the target folder.
        if ( data.folder === this.id ) data.folder = options.folder;

        // Otherwise retain their folder structure if keepFolders is true.
        else data.folder = options.keepFolders ? folderData._id : options.folder;

        // Generate thumbnails for Scenes
        if ( doc instanceof Scene ) {
          const { thumb } = await doc.createThumbnail({ img: data.background.src });
          data.thumb = thumb;
        }

        // Classify document data for creation or update
        const existing = updateByName ? index.find(i => i.name === data.name) : index.find(i => i._id === data._id);
        if ( existing ) {
          data._id = existing._id;
          documentsToUpdate.push(data);
        }
        else documentsToCreate.push(data);
        console.log(`Prepared "${data.name}" for export to "${pack.collection}"`);
      }

      // Iterate over subfolders of the Folder, preparing each for export
      for ( let c of folder.children ) await _extractFolder(c.folder, _depth+1);
    };

    // Prepare folders for export
    try {
      await _extractFolder(this, 0);
    } catch(err) {
      const msg = `Cannot export Folder "${this.name}" to Compendium pack "${pack.collection}":\n${err.message}`;
      return ui.notifications.error(msg, {console: true});
    }

    // Create and update Folders
    if ( foldersToUpdate.length ) {
      await this.constructor.updateDocuments(foldersToUpdate, {
        pack: pack.collection,
        diff: false,
        recursive: false,
        render: false
      });
    }
    if ( foldersToCreate.length ) {
      await this.constructor.createDocuments(foldersToCreate, {
        pack: pack.collection,
        keepId: true,
        render: false
      });
    }

    // Create and update Documents
    const cls = pack.documentClass;
    if ( documentsToUpdate.length ) await cls.updateDocuments(documentsToUpdate, {
      pack: pack.collection,
      diff: false,
      recursive: false,
      render: false
    });
    if ( documentsToCreate.length ) await cls.createDocuments(documentsToCreate, {
      pack: pack.collection,
      keepId: options.keepId,
      render: false
    });

    // Re-render the pack
    ui.notifications.info(game.i18n.format("FOLDER.ExportDone", {
      type: game.i18n.localize(getDocumentClass(this.type).metadata.labelPlural), compendium: pack.collection}));
    pack.render(false);
    return pack;
  }

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

  /**
   * Provide a dialog form that allows for exporting the contents of a Folder into an eligible Compendium pack.
   * @param {string} pack       A pack ID to set as the default choice in the select input
   * @param {object} options    Additional options passed to the Dialog.prompt method
   * @returns {Promise<void>}   A Promise which resolves or rejects once the dialog has been submitted or closed
   */
  async exportDialog(pack, options={}) {

    // Get eligible pack destinations
    const packs = game.packs.filter(p => (p.documentName === this.type) && !p.locked);
    if ( !packs.length ) {
      return ui.notifications.warn(game.i18n.format("FOLDER.ExportWarningNone", {
        type: game.i18n.localize(getDocumentClass(this.type).metadata.label)}));
    }

    // Render the HTML form
    const html = await renderTemplate("templates/sidebar/apps/folder-export.html", {
      packs: packs.reduce((obj, p) => {
        obj[p.collection] = p.title;
        return obj;
      }, {}),
      pack: options.pack ?? null,
      merge: options.merge ?? true,
      keepId: options.keepId ?? true,
      keepFolders: options.keepFolders ?? true,
      hasFolders: options.pack?.folders?.length ?? false,
      folders: options.pack?.folders?.map(f => ({id: f.id, name: f.name})) || [],
    });

    // Display it as a dialog prompt
    return FolderExport.prompt({
      title: `${game.i18n.localize("FOLDER.ExportTitle")}: ${this.name}`,
      content: html,
      label: game.i18n.localize("FOLDER.ExportTitle"),
      callback: html => {
        const form = html[0].querySelector("form");
        const pack = game.packs.get(form.pack.value);
        return this.exportToCompendium(pack, {
          updateByName: form.merge.checked,
          keepId: form.keepId.checked,
          keepFolders: form.keepFolders.checked,
          folder: form.folder.value
        });
      },
      rejectClose: false,
      options
    });
  }

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

  /**
   * Get the Folder documents which are sub-folders of the current folder, either direct children or recursively.
   * @param {boolean} [recursive=false] Identify child folders recursively, if false only direct children are returned
   * @returns {Folder[]}  An array of Folder documents which are subfolders of this one
   */
  getSubfolders(recursive=false) {
    let subfolders = game.folders.filter(f => f._source.folder === this.id);
    if ( recursive && subfolders.length ) {
      for ( let f of subfolders ) {
        const children = f.getSubfolders(true);
        subfolders = subfolders.concat(children);
      }
    }
    return subfolders;
  }

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

  /**
   * Get the Folder documents which are parent folders of the current folder or any if its parents.
   * @returns {Folder[]}    An array of Folder documents which are parent folders of this one
   */
  getParentFolders() {
    let folders = [];
    let parent = this.folder;
    while ( parent ) {
      folders.push(parent);
      parent = parent.folder;
    }
    return folders;
  }
}

/**
 * The client-side Item document which extends the common BaseItem model.
 * @extends foundry.documents.BaseItem
 * @mixes ClientDocumentMixin
 *
 * @see {@link Items}            The world-level collection of Item documents
 * @see {@link ItemSheet}     The Item configuration application
 */
class Item extends ClientDocumentMixin(foundry.documents.BaseItem) {

  /**
   * A convenience alias of Item#parent which is more semantically intuitive
   * @type {Actor|null}
   */
  get actor() {
    return this.parent instanceof Actor ? this.parent : null;
  }

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

  /**
   * Provide a thumbnail image path used to represent this document.
   * @type {string}
   */
  get thumbnail() {
    return this.img;
  }

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

  /**
   * A legacy alias of Item#isEmbedded
   * @type {boolean}
   */
  get isOwned() {
    return this.isEmbedded;
  }

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

  /**
   * Return an array of the Active Effect instances which originated from this Item.
   * The returned instances are the ActiveEffect instances which exist on the Item itself.
   * @type {ActiveEffect[]}
   */
  get transferredEffects() {
    return this.effects.filter(e => e.transfer === true);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Return a data object which defines the data schema against which dice rolls can be evaluated.
   * By default, this is directly the Item's system data, but systems may extend this to include additional properties.
   * If overriding or extending this method to add additional properties, care must be taken not to mutate the original
   * object.
   * @returns {object}
   */
  getRollData() {
    return this.system;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritdoc */
  async _preCreate(data, options, user) {
    if ( (this.parent instanceof Actor) && !CONFIG.ActiveEffect.legacyTransferral ) {
      for ( const effect of this.effects ) {
        if ( effect.transfer ) effect.updateSource(ActiveEffect.implementation.getInitialDuration());
      }
    }
    return super._preCreate(data, options, user);
  }

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

  /** @override */
  static async _onCreateOperation(documents, operation, user) {
    if ( !(operation.parent instanceof Actor) || !CONFIG.ActiveEffect.legacyTransferral || !user.isSelf ) return;
    const cls = getDocumentClass("ActiveEffect");

    // Create effect data
    const toCreate = [];
    for ( let item of documents ) {
      for ( let e of item.effects ) {
        if ( !e.transfer ) continue;
        const effectData = e.toJSON();
        effectData.origin = item.uuid;
        toCreate.push(effectData);
      }
    }

    // Asynchronously create transferred Active Effects
    operation = {...operation};
    delete operation.data;
    operation.renderSheet = false;
    // noinspection ES6MissingAwait
    cls.createDocuments(toCreate, operation);
  }

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

  /** @inheritdoc */
  static async _onDeleteOperation(documents, operation, user) {
    const actor = operation.parent;
    const cls = getDocumentClass("ActiveEffect");
    if ( !(actor instanceof Actor) || !CONFIG.ActiveEffect.legacyTransferral || !user.isSelf ) return;

    // Identify effects that should be deleted
    const deletedUUIDs = new Set(documents.map(i => {
      if ( actor.isToken ) return i.uuid.split(".").slice(-2).join(".");
      return i.uuid;
    }));
    const toDelete = [];
    for ( const e of actor.effects ) {
      let origin = e.origin || "";
      if ( actor.isToken ) origin = origin.split(".").slice(-2).join(".");
      if ( deletedUUIDs.has(origin) ) toDelete.push(e.id);
    }

    // Asynchronously delete transferred Active Effects
    operation = {...operation};
    delete operation.ids;
    delete operation.deleteAll;
    // noinspection ES6MissingAwait
    cls.deleteDocuments(toDelete, operation);
  }
}

/**
 * The client-side JournalEntryPage document which extends the common BaseJournalEntryPage document model.
 * @extends foundry.documents.BaseJournalEntryPage
 * @mixes ClientDocumentMixin
 *
 * @see {@link JournalEntry}  The JournalEntry document type which contains JournalEntryPage embedded documents.
 */
class JournalEntryPage extends ClientDocumentMixin(foundry.documents.BaseJournalEntryPage) {
  /**
   * @typedef {object} JournalEntryPageHeading
   * @property {number} level                  The heading level, 1-6.
   * @property {string} text                   The raw heading text with any internal tags omitted.
   * @property {string} slug                   The generated slug for this heading.
   * @property {HTMLHeadingElement} [element]  The currently rendered element for this heading, if it exists.
   * @property {string[]} children             Any child headings of this one.
   * @property {number} order                  The linear ordering of the heading in the table of contents.
   */

  /**
   * The cached table of contents for this JournalEntryPage.
   * @type {Record<string, JournalEntryPageHeading>}
   * @protected
   */
  _toc;

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

  /**
   * The table of contents for this JournalEntryPage.
   * @type {Record<string, JournalEntryPageHeading>}
   */
  get toc() {
    if ( this.type !== "text" ) return {};
    if ( this._toc ) return this._toc;
    const renderTarget = document.createElement("template");
    renderTarget.innerHTML = this.text.content;
    this._toc = this.constructor.buildTOC(Array.from(renderTarget.content.children), {includeElement: false});
    return this._toc;
  }

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

  /** @inheritdoc */
  get permission() {
    if ( game.user.isGM ) return CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER;
    return this.getUserLevel(game.user);
  }

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

  /**
   * Return a reference to the Note instance for this Journal Entry Page in the current Scene, if any.
   * If multiple notes are placed for this Journal Entry, only the first will be returned.
   * @type {Note|null}
   */
  get sceneNote() {
    if ( !canvas.ready ) return null;
    return canvas.notes.placeables.find(n => {
      return (n.document.entryId === this.parent.id) && (n.document.pageId === this.id);
    }) || null;
  }

  /* -------------------------------------------- */
  /*  Table of Contents                           */
  /* -------------------------------------------- */

  /**
   * Convert a heading into slug suitable for use as an identifier.
   * @param {HTMLHeadingElement|string} heading  The heading element or some text content.
   * @returns {string}
   */
  static slugifyHeading(heading) {
    if ( heading instanceof HTMLElement ) heading = heading.textContent;
    return heading.slugify().replace(/["']/g, "").substring(0, 64);
  }

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

  /**
   * Build a table of contents for the given HTML content.
   * @param {HTMLElement[]} html                     The HTML content to generate a ToC outline for.
   * @param {object} [options]                       Additional options to configure ToC generation.
   * @param {boolean} [options.includeElement=true]  Include references to the heading DOM elements in the returned ToC.
   * @returns {Record<string, JournalEntryPageHeading>}
   */
  static buildTOC(html, {includeElement=true}={}) {
    // A pseudo root heading element to start at.
    const root = {level: 0, children: []};
    // Perform a depth-first-search down the DOM to locate heading nodes.
    const stack = [root];
    const searchHeadings = element => {
      if ( element instanceof HTMLHeadingElement ) {
        const node = this._makeHeadingNode(element, {includeElement});
        let parent = stack.at(-1);
        if ( node.level <= parent.level ) {
          stack.pop();
          parent = stack.at(-1);
        }
        parent.children.push(node);
        stack.push(node);
      }
      for ( const child of (element.children || []) ) {
        searchHeadings(child);
      }
    };
    html.forEach(searchHeadings);
    return this._flattenTOC(root.children);
  }

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

  /**
   * Flatten the tree structure into a single object with each node's slug as the key.
   * @param {JournalEntryPageHeading[]} nodes  The root ToC nodes.
   * @returns {Record<string, JournalEntryPageHeading>}
   * @protected
   */
  static _flattenTOC(nodes) {
    let order = 0;
    const toc = {};
    const addNode = node => {
      if ( toc[node.slug] ) {
        let i = 1;
        while ( toc[`${node.slug}$${i}`] ) i++;
        node.slug = `${node.slug}$${i}`;
      }
      node.order = order++;
      toc[node.slug] = node;
      return node.slug;
    };
    const flattenNode = node => {
      const slug = addNode(node);
      while ( node.children.length ) {
        if ( typeof node.children[0] === "string" ) break;
        const child = node.children.shift();
        node.children.push(flattenNode(child));
      }
      return slug;
    };
    nodes.forEach(flattenNode);
    return toc;
  }

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

  /**
   * Construct a table of contents node from a heading element.
   * @param {HTMLHeadingElement} heading             The heading element.
   * @param {object} [options]                       Additional options to configure the returned node.
   * @param {boolean} [options.includeElement=true]  Whether to include the DOM element in the returned ToC node.
   * @returns {JournalEntryPageHeading}
   * @protected
   */
  static _makeHeadingNode(heading, {includeElement=true}={}) {
    const node = {
      text: heading.innerText,
      level: Number(heading.tagName[1]),
      slug: heading.id || this.slugifyHeading(heading),
      children: []
    };
    if ( includeElement ) node.element = heading;
    return node;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _createDocumentLink(eventData, {relativeTo, label}={}) {
    const uuid = relativeTo ? this.getRelativeUUID(relativeTo) : this.uuid;
    if ( eventData.anchor?.slug ) {
      label ??= eventData.anchor.name;
      return `@UUID[${uuid}#${eventData.anchor.slug}]{${label}}`;
    }
    return super._createDocumentLink(eventData, {relativeTo, label});
  }

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

  /** @inheritdoc */
  _onClickDocumentLink(event) {
    const target = event.currentTarget;
    return this.parent.sheet.render(true, {pageId: this.id, anchor: target.dataset.hash});
  }

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

  /** @inheritdoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( "text.content" in foundry.utils.flattenObject(changed) ) this._toc = null;
    if ( !canvas.ready ) return;
    if ( ["name", "ownership"].some(k => k in changed) ) {
      canvas.notes.placeables.filter(n => n.page === this).forEach(n => n.draw());
    }
  }

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

  /** @inheritDoc */
  async _buildEmbedHTML(config, options={}) {
    const embed = await super._buildEmbedHTML(config, options);
    if ( !embed ) {
      if ( this.type === "text" ) return this._embedTextPage(config, options);
      else if ( this.type === "image" ) return this._embedImagePage(config, options);
    }
    return embed;
  }

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

  /** @inheritDoc */
  async _createFigureEmbed(content, config, options) {
    const figure = await super._createFigureEmbed(content, config, options);
    if ( (this.type === "image") && config.caption && !config.label && this.image.caption ) {
      const caption = figure.querySelector("figcaption > .embed-caption");
      if ( caption ) caption.innerText = this.image.caption;
    }
    return figure;
  }

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

  /**
   * Embed text page content.
   * @param {DocumentHTMLEmbedConfig & EnrichmentOptions} config  Configuration for embedding behavior. This can include
   *                                                              enrichment options to override those passed as part of
   *                                                              the root enrichment process.
   * @param {EnrichmentOptions} [options]     The original enrichment options to propagate to the embedded text page's
   *                                          enrichment.
   * @returns {Promise<HTMLElement|HTMLCollection|null>}
   * @protected
   *
   * @example Embed the content of the Journal Entry Page as a figure.
   * ```@Embed[.yDbDF1ThSfeinh3Y classes="small right"]{Special caption}```
   * becomes
   * ```html
   * <figure class="content-embed small right" data-content-embed
   *         data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y">
   *   <p>The contents of the page</p>
   *   <figcaption>
   *     <strong class="embed-caption">Special caption</strong>
   *     <cite>
   *       <a class="content-link" draggable="true" data-link
   *          data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y"
   *          data-id="yDbDF1ThSfeinh3Y" data-type="JournalEntryPage" data-tooltip="Text Page">
   *         <i class="fas fa-file-lines"></i> Text Page
   *       </a>
   *     </cite>
   *   <figcaption>
   * </figure>
   * ```
   *
   * @example Embed the content of the Journal Entry Page into the main content flow.
   * ```@Embed[.yDbDF1ThSfeinh3Y inline]```
   * becomes
   * ```html
   * <section class="content-embed" data-content-embed
   *          data-uuid="JournalEntry.ekAeXsvXvNL8rKFZ.JournalEntryPage.yDbDF1ThSfeinh3Y">
   *   <p>The contents of the page</p>
   * </section>
   * ```
   */
  async _embedTextPage(config, options={}) {
    options = { ...options, relativeTo: this };
    const {
      secrets=options.secrets,
      documents=options.documents,
      links=options.links,
      rolls=options.rolls,
      embeds=options.embeds
    } = config;
    foundry.utils.mergeObject(options, { secrets, documents, links, rolls, embeds });
    const enrichedPage = await TextEditor.enrichHTML(this.text.content, options);
    const container = document.createElement("div");
    container.innerHTML = enrichedPage;
    return container.children;
  }

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

  /**
   * Embed image page content.
   * @param {DocumentHTMLEmbedConfig} config  Configuration for embedding behavior.
   * @param {string} [config.alt]             Alt text for the image, otherwise the caption will be used.
   * @param {EnrichmentOptions} [options]     The original enrichment options for cases where the Document embed content
   *                                          also contains text that must be enriched.
   * @returns {Promise<HTMLElement|HTMLCollection|null>}
   * @protected
   *
   * @example Create an embedded image from a sibling journal entry page.
   * ```@Embed[.QnH8yGIHy4pmFBHR classes="small right"]{Special caption}```
   * becomes
   * ```html
   * <figure class="content-embed small right" data-content-embed
   *         data-uuid="JournalEntry.xFNPjbSEDbWjILNj.JournalEntryPage.QnH8yGIHy4pmFBHR">
   *   <img src="path/to/image.webp" alt="Special caption">
   *   <figcaption>
   *     <strong class="embed-caption">Special caption</strong>
   *     <cite>
   *       <a class="content-link" draggable="true" data-link
   *          data-uuid="JournalEntry.xFNPjbSEDbWjILNj.JournalEntryPage.QnH8yGIHy4pmFBHR"
   *          data-id="QnH8yGIHy4pmFBHR" data-type="JournalEntryPage" data-tooltip="Image Page">
   *         <i class="fas fa-file-image"></i> Image Page
   *       </a>
   *     </cite>
   *   </figcaption>
   * </figure>
   * ```
   */
  async _embedImagePage({ alt, label }, options={}) {
    const img = document.createElement("img");
    img.src = this.src;
    img.alt = alt || label || this.image.caption || this.name;
    return img;
  }
}

/**
 * The client-side JournalEntry document which extends the common BaseJournalEntry model.
 * @extends foundry.documents.BaseJournalEntry
 * @mixes ClientDocumentMixin
 *
 * @see {@link Journal}                       The world-level collection of JournalEntry documents
 * @see {@link JournalSheet}                  The JournalEntry configuration application
 */
class JournalEntry extends ClientDocumentMixin(foundry.documents.BaseJournalEntry) {

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

  /**
   * A boolean indicator for whether the JournalEntry is visible to the current user in the directory sidebar
   * @type {boolean}
   */
  get visible() {
    return this.testUserPermission(game.user, "OBSERVER");
  }

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

  /** @inheritdoc */
  getUserLevel(user) {
    // Upgrade to OBSERVER ownership if the journal entry is in a LIMITED compendium, as LIMITED has no special meaning
    // for journal entries in this context.
    if ( this.pack && (this.compendium.getUserLevel(user) === CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED) ) {
      return CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER;
    }
    return super.getUserLevel(user);
  }

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

  /**
   * Return a reference to the Note instance for this Journal Entry in the current Scene, if any.
   * If multiple notes are placed for this Journal Entry, only the first will be returned.
   * @type {Note|null}
   */
  get sceneNote() {
    if ( !canvas.ready ) return null;
    return canvas.notes.placeables.find(n => n.document.entryId === this.id) || null;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Show the JournalEntry to connected players.
   * By default, the entry will only be shown to players who have permission to observe it.
   * If the parameter force is passed, the entry will be shown to all players regardless of normal permission.
   *
   * @param {boolean} [force=false]    Display the entry to all players regardless of normal permissions
   * @returns {Promise<JournalEntry>}  A Promise that resolves back to the shown entry once the request is processed
   * @alias Journal.show
   */
  async show(force=false) {
    return Journal.show(this, {force});
  }

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

  /**
   * If the JournalEntry has a pinned note on the canvas, this method will animate to that note
   * The note will also be highlighted as if hovered upon by the mouse
   * @param {object} [options={}]         Options which modify the pan operation
   * @param {number} [options.scale=1.5]          The resulting zoom level
   * @param {number} [options.duration=250]       The speed of the pan animation in milliseconds
   * @returns {Promise<void>}             A Promise which resolves once the pan animation has concluded
   */
  panToNote(options={}) {
    return canvas.notes.panToNote(this.sceneNote, options);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( !canvas.ready ) return;
    if ( ["name", "ownership"].some(k => k in changed) ) {
      canvas.notes.placeables.filter(n => n.document.entryId === this.id).forEach(n => n.draw());
    }
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( !canvas.ready ) return;
    for ( let n of canvas.notes.placeables ) {
      if ( n.document.entryId === this.id ) n.draw();
    }
  }
}

/**
 * The client-side Macro document which extends the common BaseMacro model.
 * @extends foundry.documents.BaseMacro
 * @mixes ClientDocumentMixin
 *
 * @see {@link Macros}                       The world-level collection of Macro documents
 * @see {@link MacroConfig}                  The Macro configuration application
 */
class Macro extends ClientDocumentMixin(foundry.documents.BaseMacro) {

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

  /**
   * Is the current User the author of this macro?
   * @type {boolean}
   */
  get isAuthor() {
    return game.user === this.author;
  }

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

  /**
   * Test whether the current User is capable of executing this Macro.
   * @type {boolean}
   */
  get canExecute() {
    return this.canUserExecute(game.user);
  }

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

  /**
   * Provide a thumbnail image path used to represent this document.
   * @type {string}
   */
  get thumbnail() {
    return this.img;
  }

  /* -------------------------------------------- */
  /*  Model Methods                               */
  /* -------------------------------------------- */

  /**
   * Test whether the given User is capable of executing this Macro.
   * @param {User} user    The User to test.
   * @returns {boolean}    Can this User execute this Macro?
   */
  canUserExecute(user) {
    if ( !this.testUserPermission(user, "LIMITED") ) return false;
    return this.type === "script" ? user.can("MACRO_SCRIPT") : true;
  }

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

  /**
   * Execute the Macro command.
   * @param {object} [scope={}]     Macro execution scope which is passed to script macros
   * @param {ChatSpeakerData} [scope.speaker]   The speaker data
   * @param {Actor} [scope.actor]     An Actor who is the protagonist of the executed action
   * @param {Token} [scope.token]     A Token which is the protagonist of the executed action
   * @param {Event|RegionEvent} [scope.event]   An optional event passed to the executed macro
   * @returns {Promise<unknown>|void} A promising containing a created {@link ChatMessage} (or `undefined`) if a chat
   *                                  macro or the return value if a script macro. A void return is possible if the user
   *                                  is not permitted to execute macros or a script macro execution fails.
   */
  execute(scope={}) {
    if ( !this.canExecute ) {
      ui.notifications.warn(`You do not have permission to execute Macro "${this.name}".`);
      return;
    }
    switch ( this.type ) {
      case "chat":
        return this.#executeChat(scope.speaker);
      case "script":
        if ( foundry.utils.getType(scope) !== "Object" ) {
          throw new Error("Invalid scope parameter passed to Macro#execute which must be an object");
        }
        return this.#executeScript(scope);
    }
  }

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

  /**
   * Execute the command as a chat macro.
   * Chat macros simulate the process of the command being entered into the Chat Log input textarea.
   * @param {ChatSpeakerData} [speaker]   The speaker data
   * @returns {Promise<ChatMessage|void>} A promising that resolves to either a created chat message or void in case an
   *                                      error is thrown or the message's creation is prevented by some other means
   *                                      (e.g., a hook).
   */
  #executeChat(speaker) {
    return ui.chat.processMessage(this.command, {speaker}).catch(err => {
      Hooks.onError("Macro#_executeChat", err, {
        msg: "There was an error in your chat message syntax.",
        log: "error",
        notify: "error",
        command: this.command
      });
    });
  }

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

  /**
   * Execute the command as a script macro.
   * Script Macros are wrapped in an async IIFE to allow the use of asynchronous commands and await statements.
   * @param {object} [scope={}]     Macro execution scope which is passed to script macros
   * @param {ChatSpeakerData} [scope.speaker]   The speaker data
   * @param {Actor} [scope.actor]     An Actor who is the protagonist of the executed action
   * @param {Token} [scope.token]     A Token which is the protagonist of the executed action
   * @returns {Promise<unknown>|void} A promise containing the return value of the macro, if any, or nothing if the
   *                                  macro execution throws an error.
   */
  #executeScript({speaker, actor, token, ...scope}={}) {

    // Add variables to the evaluation scope
    speaker = speaker || ChatMessage.implementation.getSpeaker({actor, token});
    const character = game.user.character;
    token = token || (canvas.ready ? canvas.tokens.get(speaker.token) : null) || null;
    actor = actor || token?.actor || game.actors.get(speaker.actor) || null;

    // Unpack argument names and values
    const argNames = Object.keys(scope);
    if ( argNames.some(k => Number.isNumeric(k)) ) {
      throw new Error("Illegal numeric Macro parameter passed to execution scope.");
    }
    const argValues = Object.values(scope);

    // Define an AsyncFunction that wraps the macro content
    // eslint-disable-next-line no-new-func
    const fn = new foundry.utils.AsyncFunction("speaker", "actor", "token", "character", "scope", ...argNames,
      `{${this.command}\n}`);

    // Attempt macro execution
    try {
      return fn.call(this, speaker, actor, token, character, scope, ...argValues);
    } catch(err) {
      ui.notifications.error("MACRO.Error", { localize: true });
    }
  }

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

  /** @inheritdoc */
  _onClickDocumentLink(event) {
    return this.execute({event});
  }
}

/**
 * The client-side MeasuredTemplate document which extends the common BaseMeasuredTemplate document model.
 * @extends foundry.documents.BaseMeasuredTemplate
 * @mixes ClientDocumentMixin
 *
 * @see {@link Scene}                     The Scene document type which contains MeasuredTemplate documents
 * @see {@link MeasuredTemplateConfig}    The MeasuredTemplate configuration application
 */
class MeasuredTemplateDocument extends CanvasDocumentMixin(foundry.documents.BaseMeasuredTemplate) {

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

  /**
   * Rotation is an alias for direction
   * @returns {number}
   */
  get rotation() {
    return this.direction;
  }

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

  /**
   * Is the current User the author of this template?
   * @type {boolean}
   */
  get isAuthor() {
    return game.user === this.author;
  }
}

/**
 * The client-side Note document which extends the common BaseNote document model.
 * @extends foundry.documents.BaseNote
 * @mixes ClientDocumentMixin
 *
 * @see {@link Scene}                     The Scene document type which contains Note documents
 * @see {@link NoteConfig}                The Note configuration application
 */
class NoteDocument extends CanvasDocumentMixin(foundry.documents.BaseNote) {

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

  /**
   * The associated JournalEntry which is referenced by this Note
   * @type {JournalEntry}
   */
  get entry() {
    return game.journal.get(this.entryId);
  }

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

  /**
   * The specific JournalEntryPage within the associated JournalEntry referenced by this Note.
   * @type {JournalEntryPage}
   */
  get page() {
    return this.entry?.pages.get(this.pageId);
  }

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

  /**
   * The text label used to annotate this Note
   * @type {string}
   */
  get label() {
    return this.text || this.page?.name || this.entry?.name || game?.i18n?.localize("NOTE.Unknown") || "Unknown";
  }
}

/**
 * The client-side PlaylistSound document which extends the common BasePlaylistSound model.
 * Each PlaylistSound belongs to the sounds collection of a Playlist document.
 * @extends foundry.documents.BasePlaylistSound
 * @mixes ClientDocumentMixin
 *
 * @see {@link Playlist}              The Playlist document which contains PlaylistSound embedded documents
 * @see {@link PlaylistSoundConfig}   The PlaylistSound configuration application
 * @see {@link foundry.audio.Sound}   The Sound API which manages web audio playback
 */
class PlaylistSound extends ClientDocumentMixin(foundry.documents.BasePlaylistSound) {

  /**
   * The debounce tolerance for processing rapid volume changes into database updates in milliseconds
   * @type {number}
   */
  static VOLUME_DEBOUNCE_MS = 100;

  /**
   * The Sound which manages playback for this playlist sound.
   * The Sound is created lazily when playback is required.
   * @type {Sound|null}
   */
  sound;

  /**
   * A debounced function, accepting a single volume parameter to adjust the volume of this sound
   * @type {function(number): void}
   * @param {number} volume     The desired volume level
   */
  debounceVolume = foundry.utils.debounce(volume => {
    this.update({volume}, {diff: false, render: false});
  }, PlaylistSound.VOLUME_DEBOUNCE_MS);

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

  /**
   * Create a Sound used to play this PlaylistSound document
   * @returns {Sound|null}
   * @protected
   */
  _createSound() {
    if ( game.audio.locked ) {
      throw new Error("You may not call PlaylistSound#_createSound until after game audio is unlocked.");
    }
    if ( !(this.id && this.path) ) return null;
    const sound = game.audio.create({src: this.path, context: this.context, singleton: false});
    sound.addEventListener("play", this._onStart.bind(this));
    sound.addEventListener("end", this._onEnd.bind(this));
    sound.addEventListener("stop", this._onStop.bind(this));
    return sound;
  }

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

  /**
   * Determine the fade duration for this PlaylistSound based on its own configuration and that of its parent.
   * @type {number}
   */
  get fadeDuration() {
    if ( !this.sound.duration ) return 0;
    const halfDuration = Math.ceil(this.sound.duration / 2) * 1000;
    return Math.clamp(this.fade ?? this.parent.fade ?? 0, 0, halfDuration);
  }

  /**
   * The audio context within which this sound is played.
   * This will be undefined if the audio context is not yet active.
   * @type {AudioContext|undefined}
   */
  get context() {
    const channel = (this.channel || this.parent.channel) ?? "music";
    return game.audio[channel];
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Synchronize playback for this particular PlaylistSound instance.
   */
  sync() {

    // Conclude playback
    if ( !this.playing ) {
      if ( this.sound?.playing ) {
        this.sound.stop({fade: this.pausedTime ? 0 : this.fadeDuration, volume: 0});
      }
      return;
    }

    // Create a Sound if necessary
    this.sound ||= this._createSound();
    const sound = this.sound;
    if ( !sound || sound.failed ) return;

    // Update an already playing sound
    if ( sound.playing ) {
      sound.loop = this.repeat;
      sound.fade(this.volume, {duration: 500});
      return;
    }

    // Begin playback
    sound.load({autoplay: true, autoplayOptions: {
      loop: this.repeat,
      volume: this.volume,
      fade: this.fade,
      offset: this.pausedTime && !sound.playing ? this.pausedTime : undefined
    }});
  }

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

  /**
   * Load the audio for this sound for the current client.
   * @returns {Promise<void>}
   */
  async load() {
    this.sound ||= this._createSound();
    await this.sound.load();
  }

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

  /** @inheritdoc */
  toAnchor({classes=[], ...options}={}) {
    if ( this.playing ) classes.push("playing");
    if ( !this.isOwner ) classes.push("disabled");
    return super.toAnchor({classes, ...options});
  }

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

  /** @inheritdoc */
  _onClickDocumentLink(event) {
    if ( this.playing ) return this.parent.stopSound(this);
    return this.parent.playSound(this);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( this.parent ) this.parent._playbackOrder = undefined;
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( "path" in changed ) {
      if ( this.sound ) this.sound.stop();
      this.sound = this._createSound();
    }
    if ( ("sort" in changed) && this.parent ) {
      this.parent._playbackOrder = undefined;
    }
    this.sync();
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( this.parent ) this.parent._playbackOrder = undefined;
    this.playing = false;
    this.sync();
  }

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

  /**
   * Special handling that occurs when playback of a PlaylistSound is started.
   * @protected
   */
  async _onStart() {
    if ( !this.playing ) return this.sound.stop();
    const {volume, fadeDuration} = this;

    // Immediate fade-in
    if ( fadeDuration ) {
      // noinspection ES6MissingAwait
      this.sound.fade(volume, {duration: fadeDuration});
    }

    // Schedule fade-out
    if ( !this.repeat && Number.isFinite(this.sound.duration) ) {
      const fadeOutTime = this.sound.duration - (fadeDuration / 1000);
      const fadeOut = () => this.sound.fade(0, {duration: fadeDuration});
      // noinspection ES6MissingAwait
      this.sound.schedule(fadeOut, fadeOutTime);
    }

    // Playlist-level orchestration actions
    return this.parent._onSoundStart(this);
  }

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

  /**
   * Special handling that occurs when a PlaylistSound reaches the natural conclusion of its playback.
   * @protected
   */
  async _onEnd() {
    if ( !this.parent.isOwner ) return;
    return this.parent._onSoundEnd(this);
  }

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

  /**
   * Special handling that occurs when a PlaylistSound is manually stopped before its natural conclusion.
   * @protected
   */
  async _onStop() {}

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

  /**
   * The effective volume at which this playlist sound is played, incorporating the global playlist volume setting.
   * @type {number}
   */
  get effectiveVolume() {
    foundry.utils.logCompatibilityWarning("PlaylistSound#effectiveVolume is deprecated in favor of using"
      + " PlaylistSound#volume directly", {since: 12, until: 14});
    return this.volume;
  }
}

/**
 * The client-side Playlist document which extends the common BasePlaylist model.
 * @extends foundry.documents.BasePlaylist
 * @mixes ClientDocumentMixin
 *
 * @see {@link Playlists}             The world-level collection of Playlist documents
 * @see {@link PlaylistSound}         The PlaylistSound embedded document within a parent Playlist
 * @see {@link PlaylistConfig}        The Playlist configuration application
 */
class Playlist extends ClientDocumentMixin(foundry.documents.BasePlaylist) {


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

  /**
   * Playlists may have a playback order which defines the sequence of Playlist Sounds
   * @type {string[]}
   */
  _playbackOrder;

  /**
   * The order in which sounds within this playlist will be played (if sequential or shuffled)
   * Uses a stored seed for randomization to guarantee that all clients generate the same random order.
   * @type {string[]}
   */
  get playbackOrder() {
    if ( this._playbackOrder !== undefined ) return this._playbackOrder;
    switch ( this.mode ) {

      // Shuffle all tracks
      case CONST.PLAYLIST_MODES.SHUFFLE:
        let ids = this.sounds.map(s => s.id);
        const mt = new foundry.dice.MersenneTwister(this.seed ?? 0);
        let shuffle = ids.reduce((shuffle, id) => {
          shuffle[id] = mt.random();
          return shuffle;
        }, {});
        ids.sort((a, b) => shuffle[a] - shuffle[b]);
        return this._playbackOrder = ids;

      // Sorted sequential playback
      default:
        const sorted = this.sounds.contents.sort(this._sortSounds.bind(this));
        return this._playbackOrder = sorted.map(s => s.id);
    }
  }

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

  /** @inheritdoc */
  get visible() {
    return this.isOwner || this.playing;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Find all content links belonging to a given {@link Playlist} or {@link PlaylistSound}.
   * @param {Playlist|PlaylistSound} doc  The Playlist or PlaylistSound.
   * @returns {NodeListOf<Element>}
   * @protected
   */
  static _getSoundContentLinks(doc) {
    return document.querySelectorAll(`a[data-link][data-uuid="${doc.uuid}"]`);
  }

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

  /** @inheritdoc */
  prepareDerivedData() {
    this.playing = this.sounds.some(s => s.playing);
  }

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

  /**
   * Begin simultaneous playback for all sounds in the Playlist.
   * @returns {Promise<Playlist>} The updated Playlist document
   */
  async playAll() {
    if ( this.sounds.size === 0 ) return this;
    const updateData = { playing: true };
    const order = this.playbackOrder;

    // Handle different playback modes
    switch (this.mode) {

      // Soundboard Only
      case CONST.PLAYLIST_MODES.DISABLED:
        updateData.playing = false;
        break;

      // Sequential or Shuffled Playback
      case CONST.PLAYLIST_MODES.SEQUENTIAL:
      case CONST.PLAYLIST_MODES.SHUFFLE:
        const paused = this.sounds.find(s => s.pausedTime);
        const nextId = paused?.id || order[0];
        updateData.sounds = this.sounds.map(s => {
          return {_id: s.id, playing: s.id === nextId};
        });
        break;

      // Simultaneous - play all tracks
      case CONST.PLAYLIST_MODES.SIMULTANEOUS:
        updateData.sounds = this.sounds.map(s => {
          return {_id: s.id, playing: true};
        });
        break;
    }

    // Update the Playlist
    return this.update(updateData);
  }

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

  /**
   * Play the next Sound within the sequential or shuffled Playlist.
   * @param {string} [soundId]      The currently playing sound ID, if known
   * @param {object} [options={}]   Additional options which configure the next track
   * @param {number} [options.direction=1] Whether to advance forward (if 1) or backwards (if -1)
   * @returns {Promise<Playlist>}   The updated Playlist document
   */
  async playNext(soundId, {direction=1}={}) {
    if ( ![CONST.PLAYLIST_MODES.SEQUENTIAL, CONST.PLAYLIST_MODES.SHUFFLE].includes(this.mode) ) return null;

    // Determine the next sound
    if ( !soundId ) {
      const current = this.sounds.find(s => s.playing);
      soundId = current?.id || null;
    }
    let next = direction === 1 ? this._getNextSound(soundId) : this._getPreviousSound(soundId);
    if ( !this.playing ) next = null;

    // Enact playlist updates
    const sounds = this.sounds.map(s => {
      return {_id: s.id, playing: s.id === next?.id, pausedTime: null};
    });
    return this.update({sounds});
  }

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

  /**
   * Begin playback of a specific Sound within this Playlist.
   * Determine which other sounds should remain playing, if any.
   * @param {PlaylistSound} sound       The desired sound that should play
   * @returns {Promise<Playlist>}       The updated Playlist
   */
  async playSound(sound) {
    const updates = {playing: true};
    switch ( this.mode ) {
      case CONST.PLAYLIST_MODES.SEQUENTIAL:
      case CONST.PLAYLIST_MODES.SHUFFLE:
        updates.sounds = this.sounds.map(s => {
          let isPlaying = s.id === sound.id;
          return {_id: s.id, playing: isPlaying, pausedTime: isPlaying ? s.pausedTime : null};
        });
        break;
      default:
        updates.sounds = [{_id: sound.id, playing: true}];
    }
    return this.update(updates);
  }

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

  /**
   * Stop playback of a specific Sound within this Playlist.
   * Determine which other sounds should remain playing, if any.
   * @param {PlaylistSound} sound       The desired sound that should play
   * @returns {Promise<Playlist>}       The updated Playlist
   */
  async stopSound(sound) {
    return this.update({
      playing: this.sounds.some(s => (s.id !== sound.id) && s.playing),
      sounds: [{_id: sound.id, playing: false, pausedTime: null}]
    });
  }

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

  /**
   * End playback for any/all currently playing sounds within the Playlist.
   * @returns {Promise<Playlist>} The updated Playlist document
   */
  async stopAll() {
    return this.update({
      playing: false,
      sounds: this.sounds.map(s => {
        return {_id: s.id, playing: false};
      })
    });
  }

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

  /**
   * Cycle the playlist mode
   * @return {Promise.<Playlist>}   A promise which resolves to the updated Playlist instance
   */
  async cycleMode() {
    const modes = Object.values(CONST.PLAYLIST_MODES);
    let mode = this.mode + 1;
    mode = mode > Math.max(...modes) ? modes[0] : mode;
    for ( let s of this.sounds ) {
      s.playing = false;
    }
    return this.update({sounds: this.sounds.toJSON(), mode: mode});
  }

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

  /**
   * Get the next sound in the cached playback order. For internal use.
   * @private
   */
  _getNextSound(soundId) {
    const order = this.playbackOrder;
    let idx = order.indexOf(soundId);
    if (idx === order.length - 1) idx = -1;
    return this.sounds.get(order[idx+1]);
  }

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

  /**
   * Get the previous sound in the cached playback order. For internal use.
   * @private
   */
  _getPreviousSound(soundId) {
    const order = this.playbackOrder;
    let idx = order.indexOf(soundId);
    if ( idx === -1 ) idx = 1;
    else if (idx === 0) idx = order.length;
    return this.sounds.get(order[idx-1]);
  }

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

  /**
   * Define the sorting order for the Sounds within this Playlist. For internal use.
   * If sorting alphabetically, the sounds are sorted with a locale-independent comparator
   * to ensure the same order on all clients.
   * @private
   */
  _sortSounds(a, b) {
    switch ( this.sorting ) {
      case CONST.PLAYLIST_SORT_MODES.ALPHABETICAL: return a.name.compare(b.name);
      case CONST.PLAYLIST_SORT_MODES.MANUAL: return a.sort - b.sort;
    }
  }

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

  /** @inheritdoc */
  toAnchor({classes=[], ...options}={}) {
    if ( this.playing ) classes.push("playing");
    if ( !this.isOwner ) classes.push("disabled");
    return super.toAnchor({classes, ...options});
  }

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

  /** @inheritdoc */
  _onClickDocumentLink(event) {
    if ( this.playing ) return this.stopAll();
    return this.playAll();
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritdoc */
  async _preUpdate(changed, options, user) {
    if ((("mode" in changed) || ("playing" in changed)) && !("seed" in changed)) {
      changed.seed = Math.floor(Math.random() * 1000);
    }
    return super._preUpdate(changed, options, user);
  }

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

  /** @inheritdoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( "seed" in changed || "mode" in changed || "sorting" in changed ) this._playbackOrder = undefined;
    if ( ("sounds" in changed) && !game.audio.locked ) this.sounds.forEach(s => s.sync());
    this.#updateContentLinkPlaying(changed);
  }

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

  /** @inheritdoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    this.sounds.forEach(s => s.sound?.stop());
  }

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

  /** @inheritdoc */
  _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
    super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
    if ( options.render !== false ) this.collection.render();
  }

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

  /** @inheritdoc */
  _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
    if ( options.render !== false ) this.collection.render();
  }

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

  /** @inheritdoc */
  _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
    if ( options.render !== false ) this.collection.render();
  }

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

  /**
   * Handle callback logic when an individual sound within the Playlist concludes playback naturally
   * @param {PlaylistSound} sound
   * @internal
   */
  async _onSoundEnd(sound) {
    switch ( this.mode ) {
      case CONST.PLAYLIST_MODES.SEQUENTIAL:
      case CONST.PLAYLIST_MODES.SHUFFLE:
        return this.playNext(sound.id);
      case CONST.PLAYLIST_MODES.SIMULTANEOUS:
      case CONST.PLAYLIST_MODES.DISABLED:
        const updates = {playing: true, sounds: [{_id: sound.id, playing: false, pausedTime: null}]};
        for ( let s of this.sounds ) {
          if ( (s !== sound) && s.playing ) break;
          updates.playing = false;
        }
        return this.update(updates);
    }
  }

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

  /**
   * Handle callback logic when playback for an individual sound within the Playlist is started.
   * Schedule auto-preload of next track
   * @param {PlaylistSound} sound
   * @internal
   */
  async _onSoundStart(sound) {
    if ( ![CONST.PLAYLIST_MODES.SEQUENTIAL, CONST.PLAYLIST_MODES.SHUFFLE].includes(this.mode) ) return;
    const apl = CONFIG.Playlist.autoPreloadSeconds;
    if ( Number.isNumeric(apl) && Number.isFinite(sound.sound.duration) ) {
      setTimeout(() => {
        if ( !sound.playing ) return;
        const next = this._getNextSound(sound.id);
        next?.load();
      }, (sound.sound.duration - apl) * 1000);
    }
  }

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

  /**
   * Update the playing status of this Playlist in content links.
   * @param {object} changed  The data changes.
   */
  #updateContentLinkPlaying(changed) {
    if ( "playing" in changed ) {
      this.constructor._getSoundContentLinks(this).forEach(el => el.classList.toggle("playing", changed.playing));
    }
    if ( "sounds" in changed ) changed.sounds.forEach(update => {
      const sound = this.sounds.get(update._id);
      if ( !("playing" in update) || !sound ) return;
      this.constructor._getSoundContentLinks(sound).forEach(el => el.classList.toggle("playing", update.playing));
    });
  }

  /* -------------------------------------------- */
  /*  Importing and Exporting                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  toCompendium(pack, options={}) {
    const data = super.toCompendium(pack, options);
    if ( options.clearState ) {
      data.playing = false;
      for ( let s of data.sounds ) {
        s.playing = false;
      }
    }
    return data;
  }
}

/**
 * The client-side RegionBehavior document which extends the common BaseRegionBehavior model.
 * @extends foundry.documents.BaseRegionBehavior
 * @mixes ClientDocumentMixin
 */
class RegionBehavior extends ClientDocumentMixin(foundry.documents.BaseRegionBehavior) {

  /**
   * A convenience reference to the RegionDocument which contains this RegionBehavior.
   * @type {RegionDocument|null}
   */
  get region() {
    return this.parent;
  }

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

  /**
   * A convenience reference to the Scene which contains this RegionBehavior.
   * @type {Scene|null}
   */
  get scene() {
    return this.region?.parent ?? null;
  }

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

  /**
   * A RegionBehavior is active if and only if it was created, hasn't been deleted yet, and isn't disabled.
   * @type {boolean}
   */
  get active() {
    return !this.disabled && (this.region?.behaviors.get(this.id) === this)
      && (this.scene?.regions.get(this.region.id) === this.region);
  }

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

  /**
   * A RegionBehavior is viewed if and only if it is active and the Scene of its Region is viewed.
   * @type {boolean}
   */
  get viewed() {
    return this.active && (this.scene?.isView === true);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @override */
  prepareBaseData() {
    this.name ||= game.i18n.localize(CONFIG.RegionBehavior.typeLabels[this.type]);
  }

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

  /**
   * Does this RegionBehavior handle the Region events with the given name?
   * @param {string} eventName    The Region event name
   * @returns {boolean}
   */
  hasEvent(eventName) {
    const system = this.system;
    return (system instanceof foundry.data.regionBehaviors.RegionBehaviorType)
      && ((eventName in system.constructor.events) || system.events.has(eventName));
  }

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

  /**
   * Handle the Region event.
   * @param {RegionEvent} event    The Region event
   * @returns {Promise<void>}
   * @internal
   */
  async _handleRegionEvent(event) {
    const system = this.system;
    if ( !(system instanceof foundry.data.regionBehaviors.RegionBehaviorType) ) return;

    // Statically registered events for the behavior type
    if ( event.name in system.constructor.events ) {
      await system.constructor.events[event.name].call(system, event);
    }

    // Registered events specific to this behavior document
    if ( !system.events.has(event.name) ) return;
    await system._handleRegionEvent(event);
  }

  /* -------------------------------------------- */
  /*  Interaction Dialogs                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static async createDialog(data, options) {
    if ( !game.user.can("MACRO_SCRIPT") ) {
      options = {...options, types: (options?.types ?? this.TYPES).filter(t => t !== "executeScript")};
    }
    return super.createDialog(data, options);
  }
}

/**
 * @typedef {object} RegionEvent
 * @property {string} name                The name of the event
 * @property {object} data                The data of the event
 * @property {RegionDocument} region      The Region the event was triggered on
 * @property {User} user                  The User that triggered the event
 */

/**
 * @typedef {object} SocketRegionEvent
 * @property {string} regionUuid          The UUID of the Region the event was triggered on
 * @property {string} userId              The ID of the User that triggered the event
 * @property {string} eventName           The name of the event
 * @property {object} eventData           The data of the event
 * @property {string[]} eventDataUuids    The keys of the event data that are Documents
 */

/**
 * The client-side Region document which extends the common BaseRegion model.
 * @extends foundry.documents.BaseRegion
 * @mixes CanvasDocumentMixin
 */
class RegionDocument extends CanvasDocumentMixin(foundry.documents.BaseRegion) {

  /**
   * Activate the Socket event listeners.
   * @param {Socket} socket    The active game socket
   * @internal
   */
  static _activateSocketListeners(socket) {
    socket.on("regionEvent", this.#onSocketEvent.bind(this));
  }

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

  /**
   * Handle the Region event received via the socket.
   * @param {SocketRegionEvent} socketEvent    The socket Region event
   */
  static async #onSocketEvent(socketEvent) {
    const {regionUuid, userId, eventName, eventData, eventDataUuids} = socketEvent;
    const region = await fromUuid(regionUuid);
    if ( !region ) return;
    for ( const key of eventDataUuids ) {
      const uuid = foundry.utils.getProperty(eventData, key);
      const document = await fromUuid(uuid);
      foundry.utils.setProperty(eventData, key, document);
    }
    const event = {name: eventName, data: eventData, region, user: game.users.get(userId)};
    await region._handleEvent(event);
  }

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

  /**
   * Update the tokens of the given regions.
   * @param {RegionDocument[]} regions           The Regions documents, which must be all in the same Scene
   * @param {object} [options={}]                Additional options
   * @param {boolean} [options.deleted=false]    Are the Region documents deleted?
   * @param {boolean} [options.reset=true]       Reset the Token document if animated?
   *   If called during Region/Scene create/update/delete workflows, the Token documents are always reset and
   *   so never in an animated state, which means the reset option may be false. It is important that the
   *   containment test is not done in an animated state.
   * @internal
   */
  static async _updateTokens(regions, {deleted=false, reset=true}={}) {
    if ( regions.length === 0 ) return;
    const updates = [];
    const scene = regions[0].parent;
    for ( const region of regions ) {
      if ( !deleted && !region.object ) continue;
      for ( const token of scene.tokens ) {
        if ( !deleted && !token.object ) continue;
        if ( !deleted && reset && (token.object.animationContexts.size !== 0) ) token.reset();
        const inside = !deleted && token.object.testInsideRegion(region.object);
        if ( inside ) {
          if ( !token._regions.includes(region.id) ) {
            updates.push({_id: token.id, _regions: [...token._regions, region.id].sort()});
          }
        } else {
          if ( token._regions.includes(region.id) ) {
            updates.push({_id: token.id, _regions: token._regions.filter(id => id !== region.id)});
          }
        }
      }
    }
    await scene.updateEmbeddedDocuments("Token", updates);
  }

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

  /** @override */
  static async _onCreateOperation(documents, operation, user) {
    if ( user.isSelf ) {
      // noinspection ES6MissingAwait
      RegionDocument._updateTokens(documents, {reset: false});
    }
    for ( const region of documents ) {
      const status = {active: true};
      if ( region.parent.isView ) status.viewed = true;
      // noinspection ES6MissingAwait
      region._handleEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region, user});
    }
  }

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

  /** @override */
  static async _onUpdateOperation(documents, operation, user) {
    const changedRegions = [];
    for ( let i = 0; i < documents.length; i++ ) {
      const changed = operation.updates[i];
      if ( ("shapes" in changed) || ("elevation" in changed) ) changedRegions.push(documents[i]);
    }
    if ( user.isSelf ) {
      // noinspection ES6MissingAwait
      RegionDocument._updateTokens(changedRegions, {reset: false});
    }
    for ( const region of changedRegions ) {
      // noinspection ES6MissingAwait
      region._handleEvent({
        name: CONST.REGION_EVENTS.REGION_BOUNDARY,
        data: {},
        region,
        user
      });
    }
  }

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

  /** @override */
  static async _onDeleteOperation(documents, operation, user) {
    if ( user.isSelf ) {
      // noinspection ES6MissingAwait
      RegionDocument._updateTokens(documents, {deleted: true});
    }
    const regionEvents = [];
    for ( const region of documents ) {
      for ( const token of region.tokens ) {
        region.tokens.delete(token);
        regionEvents.push({
          name: CONST.REGION_EVENTS.TOKEN_EXIT,
          data: {token},
          region,
          user
        });
      }
      region.tokens.clear();
    }
    for ( const region of documents ) {
      const status = {active: false};
      if ( region.parent.isView ) status.viewed = false;
      regionEvents.push({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region, user});
    }
    for ( const event of regionEvents ) {
      // noinspection ES6MissingAwait
      event.region._handleEvent(event);
    }
  }

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

  /**
   * The tokens inside this region.
   * @type {Set<TokenDocument>}
   */
  tokens = new Set();

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

  /**
   * Trigger the Region event.
   * @param {string} eventName        The event name
   * @param {object} eventData        The event data
   * @returns {Promise<void>}
   * @internal
   */
  async _triggerEvent(eventName, eventData) {

    // Serialize Documents in the event data as UUIDs
    eventData = foundry.utils.deepClone(eventData);
    const eventDataUuids = [];
    const serializeDocuments = (object, key, path=key) => {
      const value = object[key];
      if ( (value === null) || (typeof value !== "object") ) return;
      if ( !value.constructor || (value.constructor === Object) ) {
        for ( const key in value ) serializeDocuments(value, key, `${path}.${key}`);
      } else if ( Array.isArray(value) ) {
        for ( let i = 0; i < value.length; i++ ) serializeDocuments(value, i, `${path}.${i}`);
      } else if ( value instanceof foundry.abstract.Document ) {
        object[key] = value.uuid;
        eventDataUuids.push(path);
      }
    };
    for ( const key in eventData ) serializeDocuments(eventData, key);

    // Emit socket event
    game.socket.emit("regionEvent", {
      regionUuid: this.uuid,
      userId: game.user.id,
      eventName,
      eventData,
      eventDataUuids
    });
  }

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

  /**
   * Handle the Region event.
   * @param {RegionEvent} event    The Region event
   * @returns {Promise<void>}
   * @internal
   */
  async _handleEvent(event) {
    const results = await Promise.allSettled(this.behaviors.filter(b => !b.disabled)
      .map(b => b._handleRegionEvent(event)));
    for ( const result of results ) {
      if ( result.status === "rejected" ) console.error(result.reason);
    }
  }

  /* -------------------------------------------- */
  /*  Database Event Handlers                     */
  /* -------------------------------------------- */

  /**
   * When behaviors are created within the region, dispatch events for Tokens that are already inside the region.
   * @inheritDoc
   */
  _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
    super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
    if ( collection !== "behaviors" ) return;

    // Trigger events
    const user = game.users.get(userId);
    for ( let i = 0; i < documents.length; i++ ) {
      const behavior = documents[i];
      if ( behavior.disabled ) continue;

      // Trigger status event
      const status = {active: true};
      if ( this.parent.isView ) status.viewed = true;
      behavior._handleRegionEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region: this, user});

      // Trigger enter events
      for ( const token of this.tokens ) {
        const deleted = !this.parent.tokens.has(token.id);
        if ( deleted ) continue;
        behavior._handleRegionEvent({
          name: CONST.REGION_EVENTS.TOKEN_ENTER,
          data: {token},
          region: this,
          user
        });
      }
    }
  }

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

  /**
   * When behaviors are updated within the region, dispatch events for Tokens that are already inside the region.
   * @inheritDoc
   */
  _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
    if ( collection !== "behaviors" ) return;

    // Trigger status events
    const user = game.users.get(userId);
    for ( let i = 0; i < documents.length; i++ ) {
      const disabled = changes[i].disabled;
      if ( disabled === undefined ) continue;
      const behavior = documents[i];

      // Trigger exit events
      if ( disabled ) {
        for ( const token of this.tokens ) {
          behavior._handleRegionEvent({
            name: CONST.REGION_EVENTS.TOKEN_EXIT,
            data: {token},
            region: this,
            user
          });
        }
      }

      // Triger status event
      const status = {active: !disabled};
      if ( this.parent.isView ) status.viewed = !disabled;
      behavior._handleRegionEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region: this, user});

      // Trigger enter events
      if ( !disabled ) {
        for ( const token of this.tokens ) {
          const deleted = !this.parent.tokens.has(token.id);
          if ( deleted ) continue;
          behavior._handleRegionEvent({
            name: CONST.REGION_EVENTS.TOKEN_ENTER,
            data: {token},
            region: this,
            user
          });
        }
      }
    }
  }

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

  /**
   * When behaviors are deleted within the region, dispatch events for Tokens that were previously inside the region.
   * @inheritDoc
   */
  _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    super._onDeleteDescendantDocuments(parent, collection, ids, options, userId);
    if ( collection !== "behaviors" ) return;

    // Trigger events
    const user = game.users.get(userId);
    for ( let i = 0; i < documents.length; i++ ) {
      const behavior = documents[i];
      if ( behavior.disabled ) continue;

      // Trigger exit events
      for ( const token of this.tokens ) {
        const deleted = !this.parent.tokens.has(token.id);
        if ( deleted ) continue;
        behavior._handleRegionEvent({
          name: CONST.REGION_EVENTS.TOKEN_EXIT,
          data: {token},
          region: this,
          user
        });
      }

      // Trigger status event
      const status = {active: false};
      if ( this.parent.isView ) status.viewed = false;
      behavior._handleRegionEvent({name: CONST.REGION_EVENTS.BEHAVIOR_STATUS, data: status, region: this, user});
    }
  }
}

/**
 * The client-side Scene document which extends the common BaseScene model.
 * @extends foundry.documents.BaseItem
 * @mixes ClientDocumentMixin
 *
 * @see {@link Scenes}            The world-level collection of Scene documents
 * @see {@link SceneConfig}       The Scene configuration application
 */
class Scene extends ClientDocumentMixin(foundry.documents.BaseScene) {

  /**
   * Track the viewed position of each scene (while in memory only, not persisted)
   * When switching back to a previously viewed scene, we can automatically pan to the previous position.
   * @type {CanvasViewPosition}
   */
  _viewPosition = {};

  /**
   * Track whether the scene is the active view
   * @type {boolean}
   */
  _view = this.active;

  /**
   * The grid instance.
   * @type {foundry.grid.BaseGrid}
   */
  grid = this.grid; // Workaround for subclass property instantiation issue.

  /**
   * Determine the canvas dimensions this Scene would occupy, if rendered
   * @type {object}
   */
  dimensions = this.dimensions; // Workaround for subclass property instantiation issue.

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

  /**
   * Provide a thumbnail image path used to represent this document.
   * @type {string}
   */
  get thumbnail() {
    return this.thumb;
  }

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

  /**
   * A convenience accessor for whether the Scene is currently viewed
   * @type {boolean}
   */
  get isView() {
    return this._view;
  }

  /* -------------------------------------------- */
  /*  Scene Methods                               */
  /* -------------------------------------------- */

  /**
   * Set this scene as currently active
   * @returns {Promise<Scene>}  A Promise which resolves to the current scene once it has been successfully activated
   */
  async activate() {
    if ( this.active ) return this;
    return this.update({active: true});
  }

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

  /**
   * Set this scene as the current view
   * @returns {Promise<Scene>}
   */
  async view() {

    // Do not switch if the loader is still running
    if ( canvas.loading ) {
      return ui.notifications.warn("You cannot switch Scenes until resources finish loading for your current view.");
    }

    // Switch the viewed scene
    for ( let scene of game.scenes ) {
      scene._view = scene.id === this.id;
    }

    // Notify the user in no-canvas mode
    if ( game.settings.get("core", "noCanvas") ) {
      ui.notifications.info(game.i18n.format("INFO.SceneViewCanvasDisabled", {
        name: this.navName ? this.navName : this.name
      }));
    }

    // Re-draw the canvas if the view is different
    if ( canvas.initialized && (canvas.id !== this.id) ) {
      console.log(`Foundry VTT | Viewing Scene ${this.name}`);
      await canvas.draw(this);
    }

    // Render apps for the collection
    this.collection.render();
    ui.combat.initialize();
    return this;
  }

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

  /** @override */
  clone(createData={}, options={}) {
    createData.active = false;
    createData.navigation = false;
    if ( !foundry.data.validators.isBase64Data(createData.thumb) ) delete createData.thumb;
    if ( !options.save ) return super.clone(createData, options);
    return this.createThumbnail().then(data => {
      createData.thumb = data.thumb;
      return super.clone(createData, options);
    });
  }

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

  /** @override */
  reset() {
    this._initialize({sceneReset: true});
  }

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

  /** @inheritdoc */
  toObject(source=true) {
    const object = super.toObject(source);
    if ( !source && this.grid.isHexagonal && this.flags.core?.legacyHex ) {
      object.grid.size = Math.round(this.grid.size * (2 * Math.SQRT1_3));
    }
    return object;
  }


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

  /** @inheritdoc */
  prepareBaseData() {
    this.grid = Scene.#getGrid(this);
    this.dimensions = this.getDimensions();
    this.playlistSound = this.playlist ? this.playlist.sounds.get(this._source.playlistSound) : null;
    // A temporary assumption until a more robust long-term solution when we implement Scene Levels.
    this.foregroundElevation = this.foregroundElevation || (this.grid.distance * 4);
  }

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

  /**
   * Create the grid instance from the grid config of this scene if it doesn't exist yet.
   * @param {Scene} scene
   * @returns {foundry.grid.BaseGrid}
   */
  static #getGrid(scene) {
    const grid = scene.grid;
    if ( grid instanceof foundry.grid.BaseGrid ) return grid;

    const T = CONST.GRID_TYPES;
    const type = grid.type;
    const config = {
      size: grid.size,
      distance: grid.distance,
      units: grid.units,
      style: grid.style,
      thickness: grid.thickness,
      color: grid.color,
      alpha: grid.alpha
    };

    // Gridless grid
    if ( type === T.GRIDLESS ) return new foundry.grid.GridlessGrid(config);

    // Square grid
    if ( type === T.SQUARE ) {
      config.diagonals = game.settings.get("core", "gridDiagonals");
      return new foundry.grid.SquareGrid(config);
    }

    // Hexagonal grid
    if ( type.between(T.HEXODDR, T.HEXEVENQ) ) {
      config.columns = (type === T.HEXODDQ) || (type === T.HEXEVENQ);
      config.even = (type === T.HEXEVENR) || (type === T.HEXEVENQ);
      if ( scene.flags.core?.legacyHex ) config.size *= (Math.SQRT3 / 2);
      return new foundry.grid.HexagonalGrid(config);
    }

    throw new Error("Invalid grid type");
  }

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

  /**
   * @typedef {object} SceneDimensions
   * @property {number} width        The width of the canvas.
   * @property {number} height       The height of the canvas.
   * @property {number} size         The grid size.
   * @property {Rectangle} rect      The canvas rectangle.
   * @property {number} sceneX       The X coordinate of the scene rectangle within the larger canvas.
   * @property {number} sceneY       The Y coordinate of the scene rectangle within the larger canvas.
   * @property {number} sceneWidth   The width of the scene.
   * @property {number} sceneHeight  The height of the scene.
   * @property {Rectangle} sceneRect The scene rectangle.
   * @property {number} distance     The number of distance units in a single grid space.
   * @property {number} distancePixels  The factor to convert distance units to pixels.
   * @property {string} units        The units of distance.
   * @property {number} ratio        The aspect ratio of the scene rectangle.
   * @property {number} maxR         The length of the longest line that can be drawn on the canvas.
   * @property {number} rows         The number of grid rows on the canvas.
   * @property {number} columns      The number of grid columns on the canvas.
   */

  /**
   * Get the Canvas dimensions which would be used to display this Scene.
   * Apply padding to enlarge the playable space and round to the nearest 2x grid size to ensure symmetry.
   * The rounding accomplishes that the padding buffer around the map always contains whole grid spaces.
   * @returns {SceneDimensions}
   */
  getDimensions() {

    // Get Scene data
    const grid = this.grid;
    const sceneWidth = this.width;
    const sceneHeight = this.height;

    // Compute the correct grid sizing
    let dimensions;
    if ( grid.isHexagonal && this.flags.core?.legacyHex ) {
      const legacySize = Math.round(grid.size * (2 * Math.SQRT1_3));
      dimensions = foundry.grid.HexagonalGrid._calculatePreV10Dimensions(grid.columns, legacySize,
        sceneWidth, sceneHeight, this.padding);
    } else {
      dimensions = grid.calculateDimensions(sceneWidth, sceneHeight, this.padding);
    }
    const {width, height} = dimensions;
    const sceneX = dimensions.x - this.background.offsetX;
    const sceneY = dimensions.y - this.background.offsetY;

    // Define Scene dimensions
    return {
      width, height, size: grid.size,
      rect: {x: 0, y: 0, width, height},
      sceneX, sceneY, sceneWidth, sceneHeight,
      sceneRect: {x: sceneX, y: sceneY, width: sceneWidth, height: sceneHeight},
      distance: grid.distance,
      distancePixels: grid.size / grid.distance,
      ratio: sceneWidth / sceneHeight,
      maxR: Math.hypot(width, height),
      rows: dimensions.rows,
      columns: dimensions.columns
    };
  }

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

  /** @inheritdoc */
  _onClickDocumentLink(event) {
    if ( this.journal ) return this.journal._onClickDocumentLink(event);
    return super._onClickDocumentLink(event);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;

    // Create a base64 thumbnail for the scene
    if ( !("thumb" in data) && canvas.ready && this.background.src ) {
      const t = await this.createThumbnail({img: this.background.src});
      this.updateSource({thumb: t.thumb});
    }

    // Trigger Playlist Updates
    if ( this.active ) return game.playlists._onChangeScene(this, data);
  }

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

  /** @inheritDoc */
  static async _preCreateOperation(documents, operation, user) {
    // Set a scene as active if none currently are.
    if ( !game.scenes.active ) {
      const candidate = documents.find((s, i) => !("active" in operation.data[i]));
      candidate?.updateSource({ active: true });
    }
  }

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

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);

    // Trigger Region Behavior status events
    const user = game.users.get(userId);
    for ( const region of this.regions ) {
      region._handleEvent({
        name: CONST.REGION_EVENTS.BEHAVIOR_STATUS,
        data: {active: true},
        region,
        user
      });
    }

    if ( data.active === true ) this._onActivate(true);
  }

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

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    const allowed = await super._preUpdate(changed, options, user);
    if ( allowed === false ) return false;

    // Handle darkness level lock special case
    if ( changed.environment?.darknessLevel !== undefined ) {
      const darknessLocked = this.environment.darknessLock && (changed.environment.darknessLock !== false);
      if ( darknessLocked ) delete changed.environment.darknessLevel;
    }

    if ( "thumb" in changed ) {
      options.thumb ??= [];
      options.thumb.push(this.id);
    }

    // If the canvas size has changed, translate the placeable objects
    if ( options.autoReposition ) {
      try {
        changed = this._repositionObjects(changed);
      }
      catch (err) {
        delete changed.width;
        delete changed.height;
        delete changed.padding;
        delete changed.background;
        return ui.notifications.error(err.message);
      }
    }

    const audioChange = ("active" in changed) || (this.active && ["playlist", "playlistSound"].some(k => k in changed));
    if ( audioChange ) return game.playlists._onChangeScene(this, changed);
  }

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

  /**
   * Handle repositioning of placed objects when the Scene dimensions change
   * @private
   */
  _repositionObjects(sceneUpdateData) {
    const translationScaleX = "width" in sceneUpdateData ? (sceneUpdateData.width / this.width) : 1;
    const translationScaleY = "height" in sceneUpdateData ? (sceneUpdateData.height / this.height) : 1;
    const averageTranslationScale = (translationScaleX + translationScaleY) / 2;

    // If the padding is larger than before, we need to add to it. If it's smaller, we need to subtract from it.
    const originalDimensions = this.getDimensions();
    const updatedScene = this.clone();
    updatedScene.updateSource(sceneUpdateData);
    const newDimensions = updatedScene.getDimensions();
    const paddingOffsetX = "padding" in sceneUpdateData ? ((newDimensions.width - originalDimensions.width) / 2) : 0;
    const paddingOffsetY = "padding" in sceneUpdateData ? ((newDimensions.height - originalDimensions.height) / 2) : 0;

    // Adjust for the background offset
    const backgroundOffsetX = sceneUpdateData.background?.offsetX !== undefined ? (this.background.offsetX - sceneUpdateData.background.offsetX) : 0;
    const backgroundOffsetY = sceneUpdateData.background?.offsetY !== undefined ? (this.background.offsetY - sceneUpdateData.background.offsetY) : 0;

    // If not gridless and grid size is not already being updated, adjust the grid size, ensuring the minimum
    if ( (this.grid.type !== CONST.GRID_TYPES.GRIDLESS) && !foundry.utils.hasProperty(sceneUpdateData, "grid.size") ) {
      const gridSize = Math.round(this._source.grid.size * averageTranslationScale);
      if ( gridSize < CONST.GRID_MIN_SIZE ) throw new Error(game.i18n.localize("SCENES.GridSizeError"));
      foundry.utils.setProperty(sceneUpdateData, "grid.size", gridSize);
    }

    function adjustPoint(x, y, applyOffset = true) {
      return {
        x: Math.round(x * translationScaleX + (applyOffset ? paddingOffsetX + backgroundOffsetX: 0) ),
        y: Math.round(y * translationScaleY + (applyOffset ? paddingOffsetY + backgroundOffsetY: 0) )
      }
    }

    // Placeables that have just a Position
    for ( let collection of ["tokens", "lights", "sounds", "templates"] ) {
      sceneUpdateData[collection] = this[collection].map(p => {
        const {x, y} = adjustPoint(p.x, p.y);
        return {_id: p.id, x, y};
      });
    }

    // Placeables that have a Position and a Size
    for ( let collection of ["tiles"] ) {
      sceneUpdateData[collection] = this[collection].map(p => {
        const {x, y} = adjustPoint(p.x, p.y);
        const width = Math.round(p.width * translationScaleX);
        const height = Math.round(p.height * translationScaleY);
        return {_id: p.id, x, y, width, height};
      });
    }

    // Notes have both a position and an icon size
    sceneUpdateData["notes"] = this.notes.map(p => {
      const {x, y} = adjustPoint(p.x, p.y);
      const iconSize = Math.max(32, Math.round(p.iconSize * averageTranslationScale));
      return {_id: p.id, x, y, iconSize};
    });

    // Drawings possibly have relative shape points
    sceneUpdateData["drawings"] = this.drawings.map(p => {
      const {x, y} = adjustPoint(p.x, p.y);
      const width = Math.round(p.shape.width * translationScaleX);
      const height = Math.round(p.shape.height * translationScaleY);
      let points = [];
      if ( p.shape.points ) {
        for ( let i = 0; i < p.shape.points.length; i += 2 ) {
          const {x, y} = adjustPoint(p.shape.points[i], p.shape.points[i+1], false);
          points.push(x);
          points.push(y);
        }
      }
      return {_id: p.id, x, y, "shape.width": width, "shape.height": height, "shape.points": points};
    });

    // Walls are two points
    sceneUpdateData["walls"] = this.walls.map(w => {
      const c = w.c;
      const p1 = adjustPoint(c[0], c[1]);
      const p2 = adjustPoint(c[2], c[3]);
      return {_id: w.id, c: [p1.x, p1.y, p2.x, p2.y]};
    });

    return sceneUpdateData;
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    if ( !("thumb" in changed) && (options.thumb ?? []).includes(this.id) ) changed.thumb = this.thumb;
    super._onUpdate(changed, options, userId);
    const changedKeys = new Set(Object.keys(foundry.utils.flattenObject(changed)).filter(k => k !== "_id"));

    // If the Scene became active, go through the full activation procedure
    if ( ("active" in changed) ) this._onActivate(changed.active);

    // If the Thumbnail was updated, bust the image cache
    if ( ("thumb" in changed) && this.thumb ) {
      this.thumb = `${this.thumb.split("?")[0]}?${Date.now()}`;
    }

    // Update the Regions the Token is in
    if ( (game.user.id === userId) && ["grid.type", "grid.size"].some(k => changedKeys.has(k)) ) {
      // noinspection ES6MissingAwait
      RegionDocument._updateTokens(this.regions.contents, {reset: false});
    }

    // If the scene is already active, maybe re-draw the canvas
    if ( canvas.scene === this ) {
      const redraw = [
        "foreground", "fog.overlay", "width", "height", "padding",                // Scene Dimensions
        "grid.type", "grid.size", "grid.distance", "grid.units",                  // Grid Configuration
        "drawings", "lights", "sounds", "templates", "tiles", "tokens", "walls",  // Placeable Objects
        "weather"                                                                 // Ambience
      ];
      if ( redraw.some(k => changedKeys.has(k)) || ("background" in changed) ) return canvas.draw();

      // Update grid mesh
      if ( "grid" in changed ) canvas.interface.grid.initializeMesh(this.grid);

      // Modify vision conditions
      const perceptionAttrs = ["globalLight", "tokenVision", "fog.exploration"];
      if ( perceptionAttrs.some(k => changedKeys.has(k)) ) canvas.perception.initialize();
      if ( "tokenVision" in changed ) {
        for ( const token of canvas.tokens.placeables ) token.initializeVisionSource();
      }

      // Progress darkness level
      if ( changedKeys.has("environment.darknessLevel") && options.animateDarkness ) {
        return canvas.effects.animateDarkness(changed.environment.darknessLevel, {
          duration: typeof options.animateDarkness === "number" ? options.animateDarkness : undefined
        });
      }

      // Initialize the color manager with the new darkness level and/or scene background color
      if ( ("environment" in changed)
        || ["backgroundColor", "fog.colors.unexplored", "fog.colors.explored"].some(k => changedKeys.has(k)) ) {
        canvas.environment.initialize();
      }

      // New initial view position
      if ( ["initial.x", "initial.y", "initial.scale", "width", "height"].some(k => changedKeys.has(k)) ) {
        this._viewPosition = {};
        canvas.initializeCanvasPosition();
      }

      /**
       * @type {SceneConfig}
       */
      const sheet = this.sheet;
      if ( changedKeys.has("environment.darknessLock") ) {
        // Initialize controls with a darkness lock update
        if ( ui.controls.rendered ) ui.controls.initialize();
        // Update live preview if the sheet is rendered (force all)
        if ( sheet?.rendered ) sheet._previewScene("force"); // TODO: Think about a better design
      }
    }
  }

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

  /** @inheritDoc */
  async _preDelete(options, user) {
    const allowed = await super._preDelete(options, user);
    if ( allowed === false ) return false;
    if ( this.active ) game.playlists._onChangeScene(this, {active: false});
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( canvas.scene?.id === this.id ) canvas.draw(null);
    for ( const token of this.tokens ) {
      token.baseActor?._unregisterDependentScene(this);
    }

    // Trigger Region Behavior status events
    const user = game.users.get(userId);
    for ( const region of this.regions ) {
      region._handleEvent({
        name: CONST.REGION_EVENTS.BEHAVIOR_STATUS,
        data: {active: false},
        region,
        user
      });
    }
  }

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

  /**
   * Handle Scene activation workflow if the active state is changed to true
   * @param {boolean} active    Is the scene now active?
   * @protected
   */
  _onActivate(active) {

    // Deactivate other scenes
    for ( let s of game.scenes ) {
      if ( s.active && (s !== this) ) {
        s.updateSource({active: false});
        s._initialize();
      }
    }

    // Update the Canvas display
    if ( canvas.initialized && !active ) return canvas.draw(null);
    return this.view();
  }

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

  /** @inheritdoc */
  _preCreateDescendantDocuments(parent, collection, data, options, userId) {
    super._preCreateDescendantDocuments(parent, collection, data, options, userId);

    // Record layer history for child embedded documents
    if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) {
      const layer = canvas.getCollectionLayer(collection);
      layer?.storeHistory("create", data.map(d => ({_id: d._id})));
    }
  }

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

  /** @inheritdoc */
  _preUpdateDescendantDocuments(parent, collection, changes, options, userId) {
    super._preUpdateDescendantDocuments(parent, collection, changes, options, userId);

    // Record layer history for child embedded documents
    if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) {
      const documentCollection = this.getEmbeddedCollection(collection);
      const originals = changes.reduce((data, change) => {
        const doc = documentCollection.get(change._id);
        if ( doc ) {
          const source = doc.toObject();
          const original = foundry.utils.filterObject(source, change);

          // Special handling of flag changes
          if ( "flags" in change ) {
            original.flags ??= {};
            for ( let flag in foundry.utils.flattenObject(change.flags) ) {

              // Record flags that are deleted
              if ( flag.includes(".-=") ) {
                flag = flag.replace(".-=", ".");
                foundry.utils.setProperty(original.flags, flag, foundry.utils.getProperty(source.flags, flag));
              }

              // Record flags that are added
              else if ( !foundry.utils.hasProperty(original.flags, flag) ) {
                let parent;
                for ( ;; ) {
                  const parentFlag = flag.split(".").slice(0, -1).join(".");
                  parent = parentFlag ? foundry.utils.getProperty(original.flags, parentFlag) : original.flags;
                  if ( parent !== undefined ) break;
                  flag = parentFlag;
                }
                if ( foundry.utils.getType(parent) === "Object" ) parent[`-=${flag.split(".").at(-1)}`] = null;
              }
            }
          }

          data.push(original);
        }
        return data;
      }, []);
      const layer = canvas.getCollectionLayer(collection);
      layer?.storeHistory("update", originals);
    }
  }

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

  /** @inheritdoc */
  _preDeleteDescendantDocuments(parent, collection, ids, options, userId) {
    super._preDeleteDescendantDocuments(parent, collection, ids, options, userId);

    // Record layer history for child embedded documents
    if ( (userId === game.userId) && this.isView && (parent === this) && !options.isUndo ) {
      const documentCollection = this.getEmbeddedCollection(collection);
      const originals = ids.reduce((data, id) => {
        const doc = documentCollection.get(id);
        if ( doc ) data.push(doc.toObject());
        return data;
      }, []);
      const layer = canvas.getCollectionLayer(collection);
      layer?.storeHistory("delete", originals);
    }
  }

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

  /** @inheritDoc */
  _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
    if ( (parent === this) && documents.some(doc => doc.object?.hasActiveHUD) ) {
      canvas.getCollectionLayer(collection).hud.render();
    }
  }

  /* -------------------------------------------- */
  /*  Importing and Exporting                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  toCompendium(pack, options={}) {
    const data = super.toCompendium(pack, options);
    if ( options.clearState ) delete data.fog.reset;
    if ( options.clearSort ) {
      delete data.navigation;
      delete data.navOrder;
    }
    return data;
  }

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

  /**
   * Create a 300px by 100px thumbnail image for this scene background
   * @param {object} [options]      Options which modify thumbnail creation
   * @param {string|null} [options.img]  A background image to use for thumbnail creation, otherwise the current scene
   *                          background is used.
   * @param {number} [options.width]        The desired thumbnail width. Default is 300px
   * @param {number} [options.height]       The desired thumbnail height. Default is 100px;
   * @param {string} [options.format]       Which image format should be used? image/png, image/jpg, or image/webp
   * @param {number} [options.quality]      What compression quality should be used for jpeg or webp, between 0 and 1
   * @returns {Promise<object>}      The created thumbnail data.
   */
  async createThumbnail({img, width=300, height=100, format="image/webp", quality=0.8}={}) {
    if ( game.settings.get("core", "noCanvas") ) throw new Error(game.i18n.localize("SCENES.GenerateThumbNoCanvas"));

    // Create counter-factual scene data
    const newImage = img !== undefined;
    img = img ?? this.background.src;
    const scene = this.clone({"background.src": img});

    // Load required textures to create the thumbnail
    const tiles = this.tiles.filter(t => t.texture.src && !t.hidden);
    const toLoad = tiles.map(t => t.texture.src);
    if ( img ) toLoad.push(img);
    if ( this.foreground ) toLoad.push(this.foreground);
    await TextureLoader.loader.load(toLoad);

    // Update the cloned image with new background image dimensions
    const backgroundTexture = img ? getTexture(img) : null;
    if ( newImage && backgroundTexture ) {
      scene.updateSource({width: backgroundTexture.width, height: backgroundTexture.height});
    }
    const d = scene.getDimensions();

    // Create a container and add a transparent graphic to enforce the size
    const baseContainer = new PIXI.Container();
    const sceneRectangle = new PIXI.Rectangle(0, 0, d.sceneWidth, d.sceneHeight);
    const baseGraphics = baseContainer.addChild(new PIXI.LegacyGraphics());
    baseGraphics.beginFill(0xFFFFFF, 1.0).drawShape(sceneRectangle).endFill();
    baseGraphics.zIndex = -1;
    baseContainer.mask = baseGraphics;

    // Simulate the way a sprite is drawn
    const drawTile = async tile => {
      const tex = getTexture(tile.texture.src);
      if ( !tex ) return;
      const s = new PIXI.Sprite(tex);
      const {x, y, rotation, width, height} = tile;
      const {scaleX, scaleY, tint} = tile.texture;
      s.anchor.set(0.5, 0.5);
      s.width = Math.abs(width);
      s.height = Math.abs(height);
      s.scale.x *= scaleX;
      s.scale.y *= scaleY;
      s.tint = tint;
      s.position.set(x + (width/2) - d.sceneRect.x, y + (height/2) - d.sceneRect.y);
      s.angle = rotation;
      s.elevation = tile.elevation;
      s.zIndex = tile.sort;
      return s;
    };

    // Background container
    if ( backgroundTexture ) {
      const bg = new PIXI.Sprite(backgroundTexture);
      bg.width = d.sceneWidth;
      bg.height = d.sceneHeight;
      bg.elevation = PrimaryCanvasGroup.BACKGROUND_ELEVATION;
      bg.zIndex = -Infinity;
      baseContainer.addChild(bg);
    }

    // Foreground container
    if ( this.foreground ) {
      const fgTex = getTexture(this.foreground);
      const fg = new PIXI.Sprite(fgTex);
      fg.width = d.sceneWidth;
      fg.height = d.sceneHeight;
      fg.elevation = scene.foregroundElevation;
      fg.zIndex = -Infinity;
      baseContainer.addChild(fg);
    }

    // Tiles
    for ( let t of tiles ) {
      const sprite = await drawTile(t);
      if ( sprite ) baseContainer.addChild(sprite);
    }

    // Sort by elevation and sort
    baseContainer.children.sort((a, b) => (a.elevation - b.elevation) || (a.zIndex - b.zIndex));

    // Render the container to a thumbnail
    const stage = new PIXI.Container();
    stage.addChild(baseContainer);
    return ImageHelper.createThumbnail(stage, {width, height, format, quality});
  }
}

/**
 * The client-side Setting document which extends the common BaseSetting model.
 * @extends foundry.documents.BaseSetting
 * @mixes ClientDocumentMixin
 *
 * @see {@link WorldSettings}       The world-level collection of Setting documents
 */
class Setting extends ClientDocumentMixin(foundry.documents.BaseSetting) {

  /**
   * The types of settings which should be constructed as a function call rather than as a class constructor.
   */
  static #PRIMITIVE_TYPES = Object.freeze([String, Number, Boolean, Array, Symbol, BigInt]);

  /**
   * The setting configuration for this setting document.
   * @type {SettingsConfig|undefined}
   */
  get config() {
    return game.settings?.settings.get(this.key);
  }

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

  /** @inheritDoc */
  _initialize(options={}) {
    super._initialize(options);
    this.value = this._castType();
  }

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

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    const onChange = this.config?.onChange;
    if ( onChange instanceof Function ) onChange(this.value, options, userId);
  }

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

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    const onChange = this.config?.onChange;
    if ( ("value" in changed) && (onChange instanceof Function) ) onChange(this.value, options, userId);
  }

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

  /**
   * Cast the value of the Setting into its defined type.
   * @returns {*}     The initialized type of the Setting document.
   * @protected
   */
  _castType() {

    // Allow undefined and null directly
    if ( (this.value === null) || (this.value === undefined) ) return this.value;

    // Undefined type stays as a string
    const type = this.config?.type;
    if ( !(type instanceof Function) ) return this.value;

    // Primitive types
    if ( Setting.#PRIMITIVE_TYPES.includes(type) ) {
      if ( (type === String) && (typeof this.value !== "string") ) return JSON.stringify(this.value);
      if ( this.value instanceof type ) return this.value;
      return type(this.value);
    }

    // DataField types
    if ( type instanceof foundry.data.fields.DataField ) {
      return type.initialize(value);
    }

    // DataModel types
    if ( foundry.utils.isSubclass(type, foundry.abstract.DataModel) ) {
      return type.fromSource(this.value);
    }

    // Constructed types
    const isConstructed = type?.prototype?.constructor === type;
    return isConstructed ? new type(this.value) : type(this.value);
  }
}

/**
 * The client-side TableResult document which extends the common BaseTableResult document model.
 * @extends foundry.documents.BaseTableResult
 * @mixes ClientDocumentMixin
 *
 * @see {@link RollTable}                The RollTable document type which contains TableResult documents
 */
class TableResult extends ClientDocumentMixin(foundry.documents.BaseTableResult) {

  /**
   * A path reference to the icon image used to represent this result
   */
  get icon() {
    return this.img || CONFIG.RollTable.resultIcon;
  }

  /** @override */
  prepareBaseData() {
    super.prepareBaseData();
    if ( game._documentsReady ) {
      if ( this.type === "document" ) {
        this.img = game.collections.get(this.documentCollection)?.get(this.documentId)?.img ?? this.img;
      } else if ( this.type === "pack" ) {
        this.img = game.packs.get(this.documentCollection)?.index.get(this.documentId)?.img ?? this.img;
      }
    }
  }

  /**
   * Prepare a string representation for the result which (if possible) will be a dynamic link or otherwise plain text
   * @returns {string}  The text to display
   */
  getChatText() {
    switch (this.type) {
      case CONST.TABLE_RESULT_TYPES.DOCUMENT:
        return `@${this.documentCollection}[${this.documentId}]{${this.text}}`;
      case CONST.TABLE_RESULT_TYPES.COMPENDIUM:
        return `@Compendium[${this.documentCollection}.${this.documentId}]{${this.text}}`;
      default:
        return this.text;
    }
  }
}

/**
 * @typedef {Object} RollTableDraw      An object containing the executed Roll and the produced results
 * @property {Roll} roll                The Dice roll which generated the draw
 * @property {TableResult[]} results    An array of drawn TableResult documents
 */

/**
 * The client-side RollTable document which extends the common BaseRollTable model.
 * @extends foundry.documents.BaseRollTable
 * @mixes ClientDocumentMixin
 *
 * @see {@link RollTables}                      The world-level collection of RollTable documents
 * @see {@link TableResult}                     The embedded TableResult document
 * @see {@link RollTableConfig}                 The RollTable configuration application
 */
class RollTable extends ClientDocumentMixin(foundry.documents.BaseRollTable) {

  /**
   * Provide a thumbnail image path used to represent this document.
   * @type {string}
   */
  get thumbnail() {
    return this.img;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Display a result drawn from a RollTable in the Chat Log along.
   * Optionally also display the Roll which produced the result and configure aspects of the displayed messages.
   *
   * @param {TableResult[]} results         An Array of one or more TableResult Documents which were drawn and should
   *                                        be displayed.
   * @param {object} [options={}]           Additional options which modify message creation
   * @param {Roll} [options.roll]                 An optional Roll instance which produced the drawn results
   * @param {Object} [options.messageData={}]     Additional data which customizes the created messages
   * @param {Object} [options.messageOptions={}]  Additional options which customize the created messages
   */
  async toMessage(results, {roll, messageData={}, messageOptions={}}={}) {
    const speaker = ChatMessage.getSpeaker();

    // Construct chat data
    const flavorKey = `TABLE.DrawFlavor${results.length > 1 ? "Plural" : ""}`;
    messageData = foundry.utils.mergeObject({
      flavor: game.i18n.format(flavorKey, {number: results.length, name: this.name}),
      user: game.user.id,
      speaker: speaker,
      rolls: [],
      sound: roll ? CONFIG.sounds.dice : null,
      flags: {"core.RollTable": this.id}
    }, messageData);
    if ( roll ) messageData.rolls.push(roll);

    // Render the chat card which combines the dice roll with the drawn results
    messageData.content = await renderTemplate(CONFIG.RollTable.resultTemplate, {
      description: await TextEditor.enrichHTML(this.description, {documents: true}),
      results: results.map(result => {
        const r = result.toObject(false);
        r.text = result.getChatText();
        r.icon = result.icon;
        return r;
      }),
      rollHTML: this.displayRoll && roll ? await roll.render() : null,
      table: this
    });

    // Create the chat message
    return ChatMessage.implementation.create(messageData, messageOptions);
  }

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

  /**
   * Draw a result from the RollTable based on the table formula or a provided Roll instance
   * @param {object} [options={}]         Optional arguments which customize the draw behavior
   * @param {Roll} [options.roll]                   An existing Roll instance to use for drawing from the table
   * @param {boolean} [options.recursive=true]      Allow drawing recursively from inner RollTable results
   * @param {TableResult[]} [options.results]       One or more table results which have been drawn
   * @param {boolean} [options.displayChat=true]    Whether to automatically display the results in chat
   * @param {string} [options.rollMode]             The chat roll mode to use when displaying the result
   * @returns {Promise<{RollTableDraw}>}  A Promise which resolves to an object containing the executed roll and the
   *                                      produced results.
   */
  async draw({roll, recursive=true, results=[], displayChat=true, rollMode}={}) {

    // If an array of results were not already provided, obtain them from the standard roll method
    if ( !results.length ) {
      const r = await this.roll({roll, recursive});
      roll = r.roll;
      results = r.results;
    }
    if ( !results.length ) return { roll, results };

    // Mark results as drawn, if replacement is not used, and we are not in a Compendium pack
    if ( !this.replacement && !this.pack) {
      const draws = this.getResultsForRoll(roll.total);
      await this.updateEmbeddedDocuments("TableResult", draws.map(r => {
        return {_id: r.id, drawn: true};
      }));
    }

    // Mark any nested table results as drawn too.
    let updates = results.reduce((obj, r) => {
      const parent = r.parent;
      if ( (parent === this) || parent.replacement || parent.pack ) return obj;
      if ( !obj[parent.id] ) obj[parent.id] = [];
      obj[parent.id].push({_id: r.id, drawn: true});
      return obj;
    }, {});

    if ( Object.keys(updates).length ) {
      updates = Object.entries(updates).map(([id, results]) => {
        return {_id: id, results};
      });
      await RollTable.implementation.updateDocuments(updates);
    }

    // Forward drawn results to create chat messages
    if ( displayChat ) {
      await this.toMessage(results, {
        roll: roll,
        messageOptions: {rollMode}
      });
    }

    // Return the roll and the produced results
    return {roll, results};
  }

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

  /**
   * Draw multiple results from a RollTable, constructing a final synthetic Roll as a dice pool of inner rolls.
   * @param {number} number               The number of results to draw
   * @param {object} [options={}]         Optional arguments which customize the draw
   * @param {Roll} [options.roll]                   An optional pre-configured Roll instance which defines the dice
   *                                                roll to use
   * @param {boolean} [options.recursive=true]      Allow drawing recursively from inner RollTable results
   * @param {boolean} [options.displayChat=true]    Automatically display the drawn results in chat? Default is true
   * @param {string} [options.rollMode]             Customize the roll mode used to display the drawn results
   * @returns {Promise<{RollTableDraw}>}  The drawn results
   */
  async drawMany(number, {roll=null, recursive=true, displayChat=true, rollMode}={}) {
    let results = [];
    let updates = [];
    const rolls = [];

    // Roll the requested number of times, marking results as drawn
    for ( let n=0; n<number; n++ ) {
      let draw = await this.roll({roll, recursive});
      if ( draw.results.length ) {
        rolls.push(draw.roll);
        results = results.concat(draw.results);
      }
      else break;

      // Mark results as drawn, if replacement is not used, and we are not in a Compendium pack
      if ( !this.replacement && !this.pack) {
        updates = updates.concat(draw.results.map(r => {
          r.drawn = true;
          return {_id: r.id, drawn: true};
        }));
      }
    }

    // Construct a Roll object using the constructed pool
    const pool = CONFIG.Dice.termTypes.PoolTerm.fromRolls(rolls);
    roll = Roll.defaultImplementation.fromTerms([pool]);

    // Commit updates to child results
    if ( updates.length ) {
      await this.updateEmbeddedDocuments("TableResult", updates, {diff: false});
    }

    // Forward drawn results to create chat messages
    if ( displayChat && results.length ) {
      await this.toMessage(results, {
        roll: roll,
        messageOptions: {rollMode}
      });
    }

    // Return the Roll and the array of results
    return {roll, results};
  }

  /* -------------------------------------------- */

  /**
   * Normalize the probabilities of rolling each item in the RollTable based on their assigned weights
   * @returns {Promise<RollTable>}
   */
  async normalize() {
    let totalWeight = 0;
    let counter = 1;
    const updates = [];
    for ( let result of this.results ) {
      const w = result.weight ?? 1;
      totalWeight += w;
      updates.push({_id: result.id, range: [counter, counter + w - 1]});
      counter = counter + w;
    }
    return this.update({results: updates, formula: `1d${totalWeight}`});
  }

  /* -------------------------------------------- */

  /**
   * Reset the state of the RollTable to return any drawn items to the table
   * @returns {Promise<RollTable>}
   */
  async resetResults() {
    const updates = this.results.map(result => ({_id: result.id, drawn: false}));
    return this.updateEmbeddedDocuments("TableResult", updates, {diff: false});
  }

  /* -------------------------------------------- */

  /**
   * Evaluate a RollTable by rolling its formula and retrieving a drawn result.
   *
   * Note that this function only performs the roll and identifies the result, the RollTable#draw function should be
   * called to formalize the draw from the table.
   *
   * @param {object} [options={}]       Options which modify rolling behavior
   * @param {Roll} [options.roll]                   An alternative dice Roll to use instead of the default table formula
   * @param {boolean} [options.recursive=true]   If a RollTable document is drawn as a result, recursively roll it
   * @param {number} [options._depth]            An internal flag used to track recursion depth
   * @returns {Promise<RollTableDraw>}  The Roll and results drawn by that Roll
   *
   * @example Draw results using the default table formula
   * ```js
   * const defaultResults = await table.roll();
   * ```
   *
   * @example Draw results using a custom roll formula
   * ```js
   * const roll = new Roll("1d20 + @abilities.wis.mod", actor.getRollData());
   * const customResults = await table.roll({roll});
   * ```
   */
  async roll({roll, recursive=true, _depth=0}={}) {

    // Prevent excessive recursion
    if ( _depth > 5 ) {
      throw new Error(`Maximum recursion depth exceeded when attempting to draw from RollTable ${this.id}`);
    }

    // If there is no formula, automatically calculate an even distribution
    if ( !this.formula ) {
      await this.normalize();
    }

    // Reference the provided roll formula
    roll = roll instanceof Roll ? roll : Roll.create(this.formula);
    let results = [];

    // Ensure that at least one non-drawn result remains
    const available = this.results.filter(r => !r.drawn);
    if ( !available.length ) {
      ui.notifications.warn(game.i18n.localize("TABLE.NoAvailableResults"));
      return {roll, results};
    }

    // Ensure that results are available within the minimum/maximum range
    const minRoll = (await roll.reroll({minimize: true})).total;
    const maxRoll = (await roll.reroll({maximize: true})).total;
    const availableRange = available.reduce((range, result) => {
      const r = result.range;
      if ( !range[0] || (r[0] < range[0]) ) range[0] = r[0];
      if ( !range[1] || (r[1] > range[1]) ) range[1] = r[1];
      return range;
    }, [null, null]);
    if ( (availableRange[0] > maxRoll) || (availableRange[1] < minRoll) ) {
      ui.notifications.warn("No results can possibly be drawn from this table and formula.");
      return {roll, results};
    }

    // Continue rolling until one or more results are recovered
    let iter = 0;
    while ( !results.length ) {
      if ( iter >= 10000 ) {
        ui.notifications.error(`Failed to draw an available entry from Table ${this.name}, maximum iteration reached`);
        break;
      }
      roll = await roll.reroll();
      results = this.getResultsForRoll(roll.total);
      iter++;
    }

    // Draw results recursively from any inner Roll Tables
    if ( recursive ) {
      let inner = [];
      for ( let result of results ) {
        let pack;
        let documentName;
        if ( result.type === CONST.TABLE_RESULT_TYPES.DOCUMENT ) documentName = result.documentCollection;
        else if ( result.type === CONST.TABLE_RESULT_TYPES.COMPENDIUM ) {
          pack = game.packs.get(result.documentCollection);
          documentName = pack?.documentName;
        }
        if ( documentName === "RollTable" ) {
          const id = result.documentId;
          const innerTable = pack ? await pack.getDocument(id) : game.tables.get(id);
          if (innerTable) {
            const innerRoll = await innerTable.roll({_depth: _depth + 1});
            inner = inner.concat(innerRoll.results);
          }
        }
        else inner.push(result);
      }
      results = inner;
    }

    // Return the Roll and the results
    return { roll, results };
  }

  /* -------------------------------------------- */

  /**
   * Handle a roll from within embedded content.
   * @param {PointerEvent} event  The originating event.
   * @protected
   */
  async _rollFromEmbeddedHTML(event) {
    await this.draw();
    const table = event.target.closest(".roll-table-embed");
    if ( !table ) return;
    let i = 0;
    const rows = table.querySelectorAll(":scope > tbody > tr");
    for ( const { drawn } of this.results ) {
      const row = rows[i++];
      row?.classList.toggle("drawn", drawn);
    }
  }

  /* -------------------------------------------- */

  /**
   * Get an Array of valid results for a given rolled total
   * @param {number} value    The rolled value
   * @returns {TableResult[]} An Array of results
   */
  getResultsForRoll(value) {
    return this.results.filter(r => !r.drawn && Number.between(value, ...r.range));
  }

  /* -------------------------------------------- */

  /**
   * @typedef {DocumentHTMLEmbedConfig} RollTableHTMLEmbedConfig
   * @property {boolean} [rollable=false]  Adds a button allowing the table to be rolled directly from its embedded
   *                                       context.
   */

  /**
   * Create embedded roll table markup.
   * @param {RollTableHTMLEmbedConfig} config Configuration for embedding behavior.
   * @param {EnrichmentOptions} [options]     The original enrichment options for cases where the Document embed content
   *                                          also contains text that must be enriched.
   * @returns {Promise<HTMLElement|null>}
   * @protected
   *
   * @example Embed the content of a Roll Table as a figure.
   * ```@Embed[RollTable.kRfycm1iY3XCvP8c]```
   * becomes
   * ```html
   * <figure class="content-embed" data-content-embed data-uuid="RollTable.kRfycm1iY3XCvP8c" data-id="kRfycm1iY3XCvP8c">
   *   <table class="roll-table-embed">
   *     <thead>
   *       <tr>
   *         <th>Roll</th>
   *         <th>Result</th>
   *       </tr>
   *     </thead>
   *     <tbody>
   *       <tr>
   *         <td>1&mdash;10</td>
   *         <td>
   *           <a class="inline-roll roll" data-mode="roll" data-formula="1d6">
   *             <i class="fas fa-dice-d20"></i>
   *             1d6
   *           </a>
   *           Orcs attack!
   *         </td>
   *       </tr>
   *       <tr>
   *         <td>11&mdash;20</td>
   *         <td>No encounter</td>
   *       </tr>
   *     </tbody>
   *   </table>
   *   <figcaption>
   *     <div class="embed-caption">
   *       <p>This is the Roll Table description.</p>
   *     </div>
   *     <cite>
   *       <a class="content-link" data-link data-uuid="RollTable.kRfycm1iY3XCvP8c" data-id="kRfycm1iY3XCvP8c"
   *          data-type="RollTable" data-tooltip="Rollable Table">
   *         <i class="fas fa-th-list"></i>
   *         Rollable Table
   *     </cite>
   *   </figcaption>
   * </figure>
   * ```
   */
  async _buildEmbedHTML(config, options={}) {
    options = { ...options, relativeTo: this };
    const rollable = config.rollable || config.values.includes("rollable");
    const results = this.results.toObject();
    results.sort((a, b) => a.range[0] - b.range[0]);
    const table = document.createElement("table");
    let rollHeader = game.i18n.localize("TABLE.Roll");
    if ( rollable ) {
      rollHeader = `
        <button type="button" data-action="rollTable" data-tooltip="TABLE.Roll"
                aria-label="${game.i18n.localize("TABLE.Roll")}" class="fas fa-dice-d20"></button>
        <span>${rollHeader}</span>
      `;
    }
    table.classList.add("roll-table-embed");
    table.classList.toggle("roll-table-rollable", rollable);
    table.innerHTML = `
      <thead>
        <tr>
          <th>${rollHeader}</th>
          <th>${game.i18n.localize("TABLE.Result")}</th>
        </tr>
      </thead>
      <tbody></tbody>
    `;
    const tbody = table.querySelector("tbody");
    for ( const { range, type, text, documentCollection, documentId, drawn } of results ) {
      const row = document.createElement("tr");
      row.classList.toggle("drawn", drawn);
      const [lo, hi] = range;
      row.innerHTML += `<td>${lo === hi ? lo : `${lo}&mdash;${hi}`}</td>`;
      let result;
      let doc;
      switch ( type ) {
        case CONST.TABLE_RESULT_TYPES.TEXT: result = await TextEditor.enrichHTML(text, options); break;
        case CONST.TABLE_RESULT_TYPES.DOCUMENT:
          doc = CONFIG[documentCollection].collection.instance?.get(documentId);
          break;
        case CONST.TABLE_RESULT_TYPES.COMPENDIUM:
          const pack = game.packs.get(documentCollection);
          doc = await pack.getDocument(documentId);
          break;
      }
      if ( result === undefined ) {
        if ( doc ) result = doc.toAnchor().outerHTML;
        else result = TextEditor.createAnchor({
          label: text, icon: "fas fa-unlink", classes: ["content-link", "broken"]
        }).outerHTML;
      }
      row.innerHTML += `<td>${result}</td>`;
      tbody.append(row);
    }
    return table;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _createFigureEmbed(content, config, options) {
    const figure = await super._createFigureEmbed(content, config, options);
    if ( config.caption && !config.label ) {
      // Add the table description as the caption.
      options = { ...options, relativeTo: this };
      const description = await TextEditor.enrichHTML(this.description, options);
      const figcaption = figure.querySelector(":scope > figcaption");
      figcaption.querySelector(":scope > .embed-caption").remove();
      const caption = document.createElement("div");
      caption.classList.add("embed-caption");
      caption.innerHTML = description;
      figcaption.insertAdjacentElement("afterbegin", caption);
    }
    return figure;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
    super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
    if ( options.render !== false ) this.collection.render();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
    if ( options.render !== false ) this.collection.render();
  }

  /* -------------------------------------------- */
  /*  Importing and Exporting                     */
  /* -------------------------------------------- */

  /** @override */
  toCompendium(pack, options={}) {
    const data = super.toCompendium(pack, options);
    if ( options.clearState ) {
      for ( let r of data.results ) {
        r.drawn = false;
      }
    }
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Create a new RollTable document using all of the Documents from a specific Folder as new results.
   * @param {Folder} folder       The Folder document from which to create a roll table
   * @param {object} options      Additional options passed to the RollTable.create method
   * @returns {Promise<RollTable>}
   */
  static async fromFolder(folder, options={}) {
    const results = folder.contents.map((e, i) => {
      return {
        text: e.name,
        type: folder.pack ? CONST.TABLE_RESULT_TYPES.COMPENDIUM : CONST.TABLE_RESULT_TYPES.DOCUMENT,
        documentCollection: folder.pack ? folder.pack : folder.type,
        documentId: e.id,
        img: e.thumbnail || e.img,
        weight: 1,
        range: [i+1, i+1],
        drawn: false
      };
    });
    options.renderSheet = options.renderSheet ?? true;
    return this.create({
      name: folder.name,
      description: `A random table created from the contents of the ${folder.name} Folder.`,
      results: results,
      formula: `1d${results.length}`
    }, options);
  }
}

/**
 * The client-side Tile document which extends the common BaseTile document model.
 * @extends foundry.documents.BaseTile
 * @mixes ClientDocumentMixin
 *
 * @see {@link Scene}                     The Scene document type which contains Tile documents
 * @see {@link TileConfig}                The Tile configuration application
 */
class TileDocument extends CanvasDocumentMixin(foundry.documents.BaseTile) {

  /** @inheritdoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    const d = this.parent?.dimensions;
    if ( !d ) return;
    const securityBuffer = Math.max(d.size / 5, 20).toNearest(0.1);
    const maxX = d.width - securityBuffer;
    const maxY = d.height - securityBuffer;
    const minX = (this.width - securityBuffer) * -1;
    const minY = (this.height - securityBuffer) * -1;
    this.x = Math.clamp(this.x.toNearest(0.1), minX, maxX);
    this.y = Math.clamp(this.y.toNearest(0.1), minY, maxY);
  }
}

/**
 * The client-side Token document which extends the common BaseToken document model.
 * @extends foundry.documents.BaseToken
 * @mixes CanvasDocumentMixin
 *
 * @see {@link Scene}                     The Scene document type which contains Token documents
 * @see {@link TokenConfig}               The Token configuration application
 */
class TokenDocument extends CanvasDocumentMixin(foundry.documents.BaseToken) {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A singleton collection which holds a reference to the synthetic token actor by its base actor's ID.
   * @type {Collection<Actor>}
   */
  actors = (function() {
    const collection = new foundry.utils.Collection();
    collection.documentClass = Actor.implementation;
    return collection;
  })();

  /* -------------------------------------------- */

  /**
   * A reference to the Actor this Token modifies.
   * If actorLink is true, then the document is the primary Actor document.
   * Otherwise, the Actor document is a synthetic (ephemeral) document constructed using the Token's ActorDelta.
   * @returns {Actor|null}
   */
  get actor() {
    return (this.isLinked ? this.baseActor : this.delta?.syntheticActor) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * A reference to the base, World-level Actor this token represents.
   * @returns {Actor}
   */
  get baseActor() {
    return game.actors.get(this.actorId);
  }

  /* -------------------------------------------- */

  /**
   * An indicator for whether the current User has full control over this Token document.
   * @type {boolean}
   */
  get isOwner() {
    if ( game.user.isGM ) return true;
    return this.actor?.isOwner ?? false;
  }

  /* -------------------------------------------- */

  /**
   * A convenient reference for whether this TokenDocument is linked to the Actor it represents, or is a synthetic copy
   * @type {boolean}
   */
  get isLinked() {
    return this.actorLink;
  }

  /* -------------------------------------------- */

  /**
   * Does this TokenDocument have the SECRET disposition and is the current user lacking the necessary permissions
   * that would reveal this secret?
   * @type {boolean}
   */
  get isSecret() {
    return (this.disposition === CONST.TOKEN_DISPOSITIONS.SECRET) && !this.testUserPermission(game.user, "OBSERVER");
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to a Combatant that represents this Token, if one is present in the current encounter.
   * @type {Combatant|null}
   */
  get combatant() {
    return game.combat?.combatants.find(c => c.tokenId === this.id) || null;
  }

  /* -------------------------------------------- */

  /**
   * An indicator for whether this Token is currently involved in the active combat encounter.
   * @type {boolean}
   */
  get inCombat() {
    return !!this.combatant;
  }

  /* -------------------------------------------- */

  /**
   * The Regions this Token is currently in.
   * @type {Set<RegionDocument>}
   */
  regions = game._documentsReady ? new Set() : null;

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _initialize(options = {}) {
    super._initialize(options);
    this.baseActor?._registerDependentToken(this);
  }

  /* -------------------------------------------- */

  /** @override */
  prepareBaseData() {

    // Initialize regions
    if ( this.regions === null ) {
      this.regions = new Set();
      if ( !this.parent ) return;
      for ( const id of this._regions ) {
        const region = this.parent.regions.get(id);
        if ( !region ) continue;
        this.regions.add(region);
        region.tokens.add(this);
      }
    }

    this.name ||= this.actor?.name || "Unknown";
    if ( this.hidden ) this.alpha = Math.min(this.alpha, game.user.isGM ? 0.5 : 0);
    this._prepareDetectionModes();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  prepareEmbeddedDocuments() {
    if ( game._documentsReady && !this.delta ) this.updateSource({ delta: { _id: this.id } });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    if ( this.ring.enabled && !this.ring.subject.texture ) {
      this.ring.subject.texture = this._inferRingSubjectTexture();
    }
  }

  /* -------------------------------------------- */

  /**
   * Infer the subject texture path to use for a token ring.
   * @returns {string}
   * @protected
   */
  _inferRingSubjectTexture() {
    let tex = this.texture.src;
    for ( const [prefix, replacement] of Object.entries(CONFIG.Token.ring.subjectPaths) ) {
      if ( tex.startsWith(prefix) ) return tex.replace(prefix, replacement);
    }
    return tex;
  }

  /* -------------------------------------------- */

  /**
   * Prepare detection modes which are available to the Token.
   * Ensure that every Token has the basic sight detection mode configured.
   * @protected
   */
  _prepareDetectionModes() {
    if ( !this.sight.enabled ) return;
    const lightMode = this.detectionModes.find(m => m.id === "lightPerception");
    if ( !lightMode ) this.detectionModes.push({id: "lightPerception", enabled: true, range: null});
    const basicMode = this.detectionModes.find(m => m.id === "basicSight");
    if ( !basicMode ) this.detectionModes.push({id: "basicSight", enabled: true, range: this.sight.range});
  }

  /* -------------------------------------------- */

  /**
   * A helper method to retrieve the underlying data behind one of the Token's attribute bars
   * @param {string} barName                The named bar to retrieve the attribute for
   * @param {object} [options]
   * @param {string} [options.alternative]  An alternative attribute path to get instead of the default one
   * @returns {object|null}                 The attribute displayed on the Token bar, if any
   */
  getBarAttribute(barName, {alternative}={}) {
    const attribute = alternative || this[barName]?.attribute;
    if ( !attribute || !this.actor ) return null;
    const system = this.actor.system;
    const isSystemDataModel = system instanceof foundry.abstract.DataModel;
    const templateModel = game.model.Actor[this.actor.type];

    // Get the current attribute value
    const data = foundry.utils.getProperty(system, attribute);
    if ( (data === null) || (data === undefined) ) return null;

    // Single values
    if ( Number.isNumeric(data) ) {
      let editable = foundry.utils.hasProperty(templateModel, attribute);
      if ( isSystemDataModel ) {
        const field = system.schema.getField(attribute);
        if ( field ) editable = field instanceof foundry.data.fields.NumberField;
      }
      return {type: "value", attribute, value: Number(data), editable};
    }

    // Attribute objects
    else if ( ("value" in data) && ("max" in data) ) {
      let editable = foundry.utils.hasProperty(templateModel, `${attribute}.value`);
      if ( isSystemDataModel ) {
        const field = system.schema.getField(`${attribute}.value`);
        if ( field ) editable = field instanceof foundry.data.fields.NumberField;
      }
      return {type: "bar", attribute, value: parseInt(data.value || 0), max: parseInt(data.max || 0), editable};
    }

    // Otherwise null
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Test whether a Token has a specific status effect.
   * @param {string} statusId     The status effect ID as defined in CONFIG.statusEffects
   * @returns {boolean}           Does the Actor of the Token have this status effect?
   */
  hasStatusEffect(statusId) {
    return this.actor?.statuses.has(statusId) ?? false;
  }

  /* -------------------------------------------- */
  /*  Combat Operations                           */
  /* -------------------------------------------- */

  /**
   * Add or remove this Token from a Combat encounter.
   * @param {object} [options={}]         Additional options passed to TokenDocument.createCombatants or
   *                                      TokenDocument.deleteCombatants
   * @param {boolean} [options.active]      Require this token to be an active Combatant or to be removed.
   *                                        Otherwise, the current combat state of the Token is toggled.
   * @returns {Promise<boolean>}          Is this Token now an active Combatant?
   */
  async toggleCombatant({active, ...options}={}) {
    active ??= !this.inCombat;
    if ( active ) await this.constructor.createCombatants([this], options);
    else await this.constructor.deleteCombatants([this], options);
    return this.inCombat;
  }

  /* -------------------------------------------- */

  /**
   * Create or remove Combatants for an array of provided Token objects.
   * @param {TokenDocument[]} tokens      The tokens which should be added to the Combat
   * @param {object} [options={}]         Options which modify the toggle operation
   * @param {Combat} [options.combat]       A specific Combat instance which should be modified. If undefined, the
   *                                        current active combat will be modified if one exists. Otherwise, a new
   *                                        Combat encounter will be created if the requesting user is a Gamemaster.
   * @returns {Promise<Combatant[]>}      An array of created Combatant documents
   */
  static async createCombatants(tokens, {combat}={}) {

    // Identify the target Combat encounter
    combat ??= game.combats.viewed;
    if ( !combat ) {
      if ( game.user.isGM ) {
        const cls = getDocumentClass("Combat");
        combat = await cls.create({scene: canvas.scene.id, active: true}, {render: false});
      }
      else throw new Error(game.i18n.localize("COMBAT.NoneActive"));
    }

    // Add tokens to the Combat encounter
    const createData = new Set(tokens).reduce((arr, token) => {
      if ( token.inCombat ) return arr;
      arr.push({tokenId: token.id, sceneId: token.parent.id, actorId: token.actorId, hidden: token.hidden});
      return arr;
    }, []);
    return combat.createEmbeddedDocuments("Combatant", createData);
  }

  /* -------------------------------------------- */

  /**
   * Remove Combatants for the array of provided Tokens.
   * @param {TokenDocument[]} tokens      The tokens which should removed from the Combat
   * @param {object} [options={}]         Options which modify the operation
   * @param {Combat} [options.combat]       A specific Combat instance from which Combatants should be deleted
   * @returns {Promise<Combatant[]>}      An array of deleted Combatant documents
   */
  static async deleteCombatants(tokens, {combat}={}) {
    combat ??= game.combats.viewed;
    const tokenIds = new Set(tokens.map(t => t.id));
    const combatantIds = combat.combatants.reduce((ids, c) => {
      if ( tokenIds.has(c.tokenId) ) ids.push(c.id);
      return ids;
    }, []);
    return combat.deleteEmbeddedDocuments("Combatant", combatantIds);
  }

  /* -------------------------------------------- */
  /*  Actor Data Operations                       */
  /* -------------------------------------------- */

  /**
   * Convenience method to change a token vision mode.
   * @param {string} visionMode       The vision mode to apply to this token.
   * @param {boolean} [defaults=true] If the vision mode should be updated with its defaults.
   * @returns {Promise<*>}
   */
  async updateVisionMode(visionMode, defaults=true) {
    if ( !(visionMode in CONFIG.Canvas.visionModes) ) {
      throw new Error("The provided vision mode does not exist in CONFIG.Canvas.visionModes");
    }
    let update = {sight: {visionMode: visionMode}};
    if ( defaults ) {
      const defaults = CONFIG.Canvas.visionModes[visionMode].vision.defaults;
      for ( const [key, value] of Object.entries(defaults)) {
        if ( value === undefined ) continue;
        update.sight[key] = value;
      }
    }
    return this.update(update);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getEmbeddedCollection(embeddedName) {
    if ( this.isLinked ) return super.getEmbeddedCollection(embeddedName);
    switch ( embeddedName ) {
      case "Actor":
        this.actors.set(this.actorId, this.actor);
        return this.actors;
      case "Item":
        return this.actor.items;
      case "ActiveEffect":
        return this.actor.effects;
    }
    return super.getEmbeddedCollection(embeddedName);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {

    // Initialize the regions of this token
    for ( const id of this._regions ) {
      const region = this.parent.regions.get(id);
      if ( !region ) continue;
      this.regions.add(region);
      region.tokens.add(this);
    }

    super._onCreate(data, options, userId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    const allowed = await super._preUpdate(changed, options, user);
    if ( allowed === false ) return false;
    if ( "actorId" in changed ) options.previousActorId = this.actorId;
    if ( "actorData" in changed ) {
      foundry.utils.logCompatibilityWarning("This update operation includes an update to the Token's actorData "
        + "property, which is deprecated. Please perform updates via the synthetic Actor instead, accessible via the "
        + "'actor' getter.", {since: 11, until: 13});
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    const configs = Object.values(this.apps).filter(app => app instanceof TokenConfig);
    configs.forEach(app => {
      if ( app.preview ) options.animate = false;
      app._previewChanges(changed);
    });

    // If the Actor association has changed, expire the cached Token actor
    if ( ("actorId" in changed) || ("actorLink" in changed) ) {
      const previousActor = game.actors.get(options.previousActorId);
      if ( previousActor ) {
        Object.values(previousActor.apps).forEach(app => app.close({submit: false}));
        previousActor._unregisterDependentToken(this);
      }
      this.delta._createSyntheticActor({ reinitializeCollections: true });
    }

    // Handle region changes
    const priorRegionIds = options._priorRegions?.[this.id];
    if ( priorRegionIds ) this.#onUpdateRegions(priorRegionIds);

    // Handle movement
    if ( game.user.id === userId ) {
      const origin = options._priorPosition?.[this.id];
      if ( origin ) this.#triggerMoveRegionEvents(origin, options.teleport === true, options.forced === true);
    }

    // Post-update the Token itself
    super._onUpdate(changed, options, userId);
    configs.forEach(app => app._previewChanges());
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to the regions this token is in.
   * @param {string[]} priorRegionIds    The IDs of the prior regions
   */
  #onUpdateRegions(priorRegionIds) {

    // Update the regions of this token
    this.regions.clear();
    for ( const id of this._regions ) {
      const region = this.parent.regions.get(id);
      if ( !region ) continue;
      this.regions.add(region);
    }

    // Update tokens of regions
    const priorRegions = new Set();
    for ( const id of priorRegionIds ) {
      const region = this.parent.regions.get(id);
      if ( region ) priorRegions.add(region);
    }
    for ( const region of priorRegions ) region.tokens.delete(this);
    for ( const region of this.regions ) region.tokens.add(this);
  }

  /* -------------------------------------------- */

  /**
   * Trigger TOKEN_MOVE, TOKEN_MOVE_IN, and TOKEN_MOVE_OUT events.
   * @param {{x: number, y: number, elevation: number}} [origin]    The origin of movement
   * @param {boolean} teleport                                      Teleporation?
   * @param {boolean} forced                                        Forced movement?
   */
  #triggerMoveRegionEvents(origin, teleport, forced) {
    if ( !this.parent.isView || !this.object ) return;
    const E = CONST.REGION_EVENTS;
    const elevation = this.elevation;
    const destination = {x: this.x, y: this.y, elevation};
    for ( const region of this.parent.regions ) {
      if ( !region.object ) continue;
      if ( !region.behaviors.some(b => !b.disabled && (b.hasEvent(E.TOKEN_MOVE)
        || b.hasEvent(E.TOKEN_MOVE_IN) || b.hasEvent(E.TOKEN_MOVE_OUT))) ) continue;
      const segments = this.object.segmentizeRegionMovement(region.object, [origin, destination], {teleport});
      if ( segments.length === 0 ) continue;
      const T = Region.MOVEMENT_SEGMENT_TYPES;
      const first = segments[0].type;
      const last = segments.at(-1).type;
      const eventData = {token: this, origin, destination, teleport, forced, segments};
      if ( (first === T.ENTER) && (last !== T.EXIT) ) region._triggerEvent(E.TOKEN_MOVE_IN, eventData);
      region._triggerEvent(E.TOKEN_MOVE, eventData);
      if ( (first !== T.ENTER) && (last === T.EXIT) ) region._triggerEvent(E.TOKEN_MOVE_OUT, eventData);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    if ( game.user.id === userId ) {
      // noinspection ES6MissingAwait
      game.combats._onDeleteToken(this.parent.id, this.id);
    }
    super._onDelete(options, userId);
    this.baseActor?._unregisterDependentToken(this);
  }

  /* -------------------------------------------- */

  /**
   * Identify the Regions the Token currently is or is going to be in after the changes are applied.
   * @param {object} [changes]    The changes.
   * @returns {string[]|void}     The Region IDs the token is (sorted), if it could be determined.
   */
  #identifyRegions(changes={}) {
    if ( !this.parent?.isView ) return;
    const regionIds = [];
    let token;
    for ( const region of this.parent.regions ) {
      if ( !region.object ) continue;
      token ??= this.clone(changes);
      const isInside = token.object.testInsideRegion(region.object);
      if ( isInside ) regionIds.push(region.id);
    }
    token?.object.destroy({children: true});
    return regionIds.sort();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static async _preCreateOperation(documents, operation, user) {
    const allowed = await super._preCreateOperation(documents, operation, user);
    if ( allowed === false ) return false;

    // Identify and set the regions the token is in
    for ( const document of documents ) document.updateSource({_regions: document.#identifyRegions() ?? []});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _preUpdateOperation(documents, operation, user) {
    const allowed = await super._preUpdateOperation(documents, operation, user);
    if ( allowed === false ) return false;
    await TokenDocument.#preUpdateMovement(documents, operation, user);
    TokenDocument.#preUpdateRegions(documents, operation, user);
  }

  /* -------------------------------------------- */

  /**
   * Handle Regions potentially stopping movement.
   * @param {TokenDocument[]} documents           Document instances to be updated
   * @param {DatabaseUpdateOperation} operation   Parameters of the database update operation
   * @param {User} user                           The User requesting the update operation
   */
  static async #preUpdateMovement(documents, operation, user) {
    if ( !operation.parent.isView ) return;

    // Handle regions stopping movement
    const teleport = operation.teleport === true;
    for ( let i = 0; i < documents.length; i++ ) {
      const document = documents[i];
      if ( !document.object ) continue;
      const changes = operation.updates[i];

      // No action need unless position/elevation is changed
      if ( !(("x" in changes) || ("y" in changes) || ("elevation" in changes)) ) continue;

      // Prepare origin and destination
      const {x: originX, y: originY, elevation: originElevation} = document;
      const origin = {x: originX, y: originY, elevation: originElevation};
      const destinationX = changes.x ?? originX;
      const destinationY = changes.y ?? originY;
      const destinationElevation = changes.elevation ?? originElevation;
      const destination = {x: destinationX, y: destinationY, elevation: destinationElevation};

      // We look for the closest position to the origin where movement is broken
      let stopDestination;
      let stopDistance;

      // Iterate regions and test movement
      for ( const region of document.parent.regions ) {
        if ( !region.object ) continue;

        // Collect behaviors that can break movement
        const behaviors = region.behaviors.filter(b => !b.disabled && b.hasEvent(CONST.REGION_EVENTS.TOKEN_PRE_MOVE));
        if ( behaviors.length === 0 ) continue;

        // Reset token so that it isn't in an animated state
        if ( document.object.animationContexts.size !== 0 ) document.reset();

        // Break the movement into its segments
        const segments = document.object.segmentizeRegionMovement(region.object, [origin, destination], {teleport});
        if ( segments.length === 0 ) continue;

        // Create the TOKEN_PRE_MOVE event
        const event = {
          name: CONST.REGION_EVENTS.TOKEN_PRE_MOVE,
          data: {token: document, origin, destination, teleport, segments},
          region,
          user
        };

        // Find the closest destination where movement is broken
        for ( const behavior of behaviors ) {

          // Dispatch event
          try {
            await behavior._handleRegionEvent(event);
          } catch(e) {
            console.error(e);
          }

          // Check if the destination of the event data was modified
          const destination = event.data.destination;
          if ( (destination.x === destinationX) && (destination.y === destinationY)
            && (destination.elevation === destinationElevation) ) continue;

          // Choose the closer destination
          const distance = Math.hypot(
            destination.x - origin.x,
            destination.y - origin.y,
            (destination.elevation - origin.elevation) * canvas.dimensions.distancePixels
          );
          if ( !stopDestination || (distance < stopDistance) ) {
            stopDestination = {x: destination.x, y: destination.y, elevation: destination.elevation};
            stopDistance = distance;
          }

          // Reset the destination
          event.data.destination = {x: destinationX, y: destinationY, elevation: destinationElevation};
        }
      }

      // Update the destination to the stop position if the movement is broken
      if ( stopDestination ) {
        changes.x = stopDestination.x;
        changes.y = stopDestination.y;
        changes.elevation = stopDestination.elevation;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Identify and update the regions this Token is going to be in if necessary.
   * @param {TokenDocument[]} documents           Document instances to be updated
   * @param {DatabaseUpdateOperation} operation   Parameters of the database update operation
   */
  static #preUpdateRegions(documents, operation) {
    if ( !operation.parent.isView ) return;

    // Update the regions the token is in
    for ( let i = 0; i < documents.length; i++ ) {
      const document = documents[i];
      const changes = operation.updates[i];
      if ( document._couldRegionsChange(changes) ) changes._regions = document.#identifyRegions(changes);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  static async _onCreateOperation(documents, operation, user) {
    for ( const token of documents ) {
      for ( const region of token.regions ) {
        // noinspection ES6MissingAwait
        region._handleEvent({
          name: CONST.REGION_EVENTS.TOKEN_ENTER,
          data: {token},
          region,
          user
        });
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  static async _onUpdateOperation(documents, operation, user) {
    if ( !operation._priorRegions ) return;
    for ( const token of documents ) {
      const priorRegionIds = operation._priorRegions[token.id];
      if ( !priorRegionIds ) continue;
      const priorRegions = new Set();
      for ( const id of priorRegionIds ) {
        const region = token.parent.regions.get(id);
        if ( region ) priorRegions.add(region);
      }
      const addedRegions = token.regions.difference(priorRegions);
      const removedRegions = priorRegions.difference(token.regions);
      for ( const region of removedRegions ) {
        // noinspection ES6MissingAwait
        region._handleEvent({
          name: CONST.REGION_EVENTS.TOKEN_EXIT,
          data: {token},
          region,
          user
        });
      }
      for ( const region of addedRegions ) {
        // noinspection ES6MissingAwait
        region._handleEvent({
          name: CONST.REGION_EVENTS.TOKEN_ENTER,
          data: {token},
          region,
          user
        });
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  static async _onDeleteOperation(documents, operation, user) {
    const regionEvents = [];
    for ( const token of documents ) {
      for ( const region of token.regions ) {
        region.tokens.delete(token);
        regionEvents.push({
          name: CONST.REGION_EVENTS.TOKEN_EXIT,
          data: {token},
          region,
          user
        });
      }
      token.regions.clear();
    }
    for ( const event of regionEvents ) {
      // noinspection ES6MissingAwait
      event.region._handleEvent(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Is to Token document updated such that the Regions the Token is contained in may change?
   * Called as part of the preUpdate workflow.
   * @param {object} changes    The changes.
   * @returns {boolean}         Could this Token update change Region containment?
   * @protected
   */
  _couldRegionsChange(changes) {
    const positionChange = ("x" in changes) || ("y" in changes);
    const elevationChange = "elevation" in changes;
    const sizeChange = ("width" in changes) || ("height" in changes);
    const shapeChange = this.parent.grid.isHexagonal && ("hexagonalShape" in changes);
    return positionChange || elevationChange || sizeChange || shapeChange;
  }

  /* -------------------------------------------- */
  /*  Actor Delta Operations                      */
  /* -------------------------------------------- */

  /**
   * Support the special case descendant document changes within an ActorDelta.
   * The descendant documents themselves are configured to have a synthetic Actor as their parent.
   * We need this to ensure that the ActorDelta receives these events which do not bubble up.
   * @inheritDoc
   */
  _preCreateDescendantDocuments(parent, collection, data, options, userId) {
    if ( parent !== this.delta ) this.delta?._handleDeltaCollectionUpdates(parent);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preUpdateDescendantDocuments(parent, collection, changes, options, userId) {
    if ( parent !== this.delta ) this.delta?._handleDeltaCollectionUpdates(parent);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preDeleteDescendantDocuments(parent, collection, ids, options, userId) {
    if ( parent !== this.delta ) this.delta?._handleDeltaCollectionUpdates(parent);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
    super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
    this._onRelatedUpdate(data, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
    this._onRelatedUpdate(changes, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
    this._onRelatedUpdate({}, options);
  }

  /* -------------------------------------------- */

  /**
   * When the base Actor for a TokenDocument changes, we may need to update its Actor instance
   * @param {object} update
   * @param {object} options
   * @internal
   */
  _onUpdateBaseActor(update={}, options={}) {

    // Update synthetic Actor data
    if ( !this.isLinked && this.delta ) {
      this.delta.updateSyntheticActor();
      for ( const collection of Object.values(this.delta.collections) ) collection.initialize({ full: true });
      this.actor.sheet.render(false, {renderContext: "updateActor"});
    }

    this._onRelatedUpdate(update, options);
  }

  /* -------------------------------------------- */

  /**
   * Whenever the token's actor delta changes, or the base actor changes, perform associated refreshes.
   * @param {object} [update]                               The update delta.
   * @param {Partial<DatabaseUpdateOperation>} [operation]  The database operation that was performed
   * @protected
   */
  _onRelatedUpdate(update={}, operation={}) {
    // Update tracked Combat resource
    const c = this.combatant;
    if ( c && foundry.utils.hasProperty(update.system || {}, game.combat.settings.resource) ) {
      c.updateResource();
    }
    if ( this.inCombat ) ui.combat.render();

    // Trigger redraws on the token
    if ( this.parent.isView ) {
      if ( this.object?.hasActiveHUD ) canvas.tokens.hud.render();
      this.object?.renderFlags.set({refreshBars: true, redrawEffects: true});
      const configs = Object.values(this.apps).filter(app => app instanceof TokenConfig);
      configs.forEach(app => {
        app.preview?.updateSource({delta: this.toObject().delta}, {diff: false, recursive: false});
        app.preview?.object?.renderFlags.set({refreshBars: true, redrawEffects: true});
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} TrackedAttributesDescription
   * @property {string[][]} bar    A list of property path arrays to attributes with both a value and a max property.
   * @property {string[][]} value  A list of property path arrays to attributes that have only a value property.
   */

  /**
   * Get an Array of attribute choices which could be tracked for Actors in the Combat Tracker
   * @param {object|DataModel|typeof DataModel|SchemaField|string} [data]  The object to explore for attributes, or an
   *                                                                       Actor type.
   * @param {string[]} [_path]
   * @returns {TrackedAttributesDescription}
   */
  static getTrackedAttributes(data, _path=[]) {
    // Case 1 - Infer attributes from schema structure.
    if ( (data instanceof foundry.abstract.DataModel) || foundry.utils.isSubclass(data, foundry.abstract.DataModel) ) {
      return this._getTrackedAttributesFromSchema(data.schema, _path);
    }
    if ( data instanceof foundry.data.fields.SchemaField ) return this._getTrackedAttributesFromSchema(data, _path);

    // Case 2 - Infer attributes from object structure.
    if ( ["Object", "Array"].includes(foundry.utils.getType(data)) ) {
      return this._getTrackedAttributesFromObject(data, _path);
    }

    // Case 3 - Retrieve explicitly configured attributes.
    if ( !data || (typeof data === "string") ) {
      const config = this._getConfiguredTrackedAttributes(data);
      if ( config ) return config;
      data = undefined;
    }

    // Track the path and record found attributes
    if ( data !== undefined ) return {bar: [], value: []};

    // Case 4 - Infer attributes from system template.
    const bar = new Set();
    const value = new Set();
    for ( let [type, model] of Object.entries(game.model.Actor) ) {
      const dataModel = CONFIG.Actor.dataModels?.[type];
      const inner = this.getTrackedAttributes(dataModel ?? model, _path);
      inner.bar.forEach(attr => bar.add(attr.join(".")));
      inner.value.forEach(attr => value.add(attr.join(".")));
    }

    return {
      bar: Array.from(bar).map(attr => attr.split(".")),
      value: Array.from(value).map(attr => attr.split("."))
    };
  }

  /* -------------------------------------------- */

  /**
   * Retrieve an Array of attribute choices from a plain object.
   * @param {object} data  The object to explore for attributes.
   * @param {string[]} _path
   * @returns {TrackedAttributesDescription}
   * @protected
   */
  static _getTrackedAttributesFromObject(data, _path=[]) {
    const attributes = {bar: [], value: []};
    // Recursively explore the object
    for ( let [k, v] of Object.entries(data) ) {
      let p = _path.concat([k]);

      // Check objects for both a "value" and a "max"
      if ( v instanceof Object ) {
        if ( k === "_source" ) continue;
        const isBar = ("value" in v) && ("max" in v);
        if ( isBar ) attributes.bar.push(p);
        else {
          const inner = this.getTrackedAttributes(data[k], p);
          attributes.bar.push(...inner.bar);
          attributes.value.push(...inner.value);
        }
      }

      // Otherwise, identify values which are numeric or null
      else if ( Number.isNumeric(v) || (v === null) ) {
        attributes.value.push(p);
      }
    }
    return attributes;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve an Array of attribute choices from a SchemaField.
   * @param {SchemaField} schema  The schema to explore for attributes.
   * @param {string[]} _path
   * @returns {TrackedAttributesDescription}
   * @protected
   */
  static _getTrackedAttributesFromSchema(schema, _path=[]) {
    const attributes = {bar: [], value: []};
    for ( const [name, field] of Object.entries(schema.fields) ) {
      const p = _path.concat([name]);
      if ( field instanceof foundry.data.fields.NumberField ) attributes.value.push(p);
      const isSchema = field instanceof foundry.data.fields.SchemaField;
      const isModel = field instanceof foundry.data.fields.EmbeddedDataField;
      if ( isSchema || isModel ) {
        const schema = isModel ? field.model.schema : field;
        const isBar = schema.has("value") && schema.has("max");
        if ( isBar ) attributes.bar.push(p);
        else {
          const inner = this.getTrackedAttributes(schema, p);
          attributes.bar.push(...inner.bar);
          attributes.value.push(...inner.value);
        }
      }
    }
    return attributes;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve any configured attributes for a given Actor type.
   * @param {string} [type]  The Actor type.
   * @returns {TrackedAttributesDescription|void}
   * @protected
   */
  static _getConfiguredTrackedAttributes(type) {

    // If trackable attributes are not configured fallback to the system template
    if ( foundry.utils.isEmpty(CONFIG.Actor.trackableAttributes) ) return;

    // If the system defines trackableAttributes per type
    let config = foundry.utils.deepClone(CONFIG.Actor.trackableAttributes[type]);

    // Otherwise union all configured trackable attributes
    if ( foundry.utils.isEmpty(config) ) {
      const bar = new Set();
      const value = new Set();
      for ( const attrs of Object.values(CONFIG.Actor.trackableAttributes) ) {
        attrs.bar.forEach(bar.add, bar);
        attrs.value.forEach(value.add, value);
      }
      config = { bar: Array.from(bar), value: Array.from(value) };
    }

    // Split dot-separate attribute paths into arrays
    Object.keys(config).forEach(k => config[k] = config[k].map(attr => attr.split(".")));
    return config;
  }

  /* -------------------------------------------- */

  /**
   * Inspect the Actor data model and identify the set of attributes which could be used for a Token Bar.
   * @param {object} attributes       The tracked attributes which can be chosen from
   * @returns {object}                A nested object of attribute choices to display
   */
  static getTrackedAttributeChoices(attributes) {
    attributes = attributes || this.getTrackedAttributes();
    const barGroup = game.i18n.localize("TOKEN.BarAttributes");
    const valueGroup = game.i18n.localize("TOKEN.BarValues");
    const bars = attributes.bar.map(v => {
      const a = v.join(".");
      return {group: barGroup, value: a, label: a};
    });
    bars.sort((a, b) => a.value.compare(b.value));
    const values = attributes.value.map(v => {
      const a = v.join(".");
      return {group: valueGroup, value: a, label: a};
    });
    values.sort((a, b) => a.value.compare(b.value));
    return bars.concat(values);
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  getActor() {
    foundry.utils.logCompatibilityWarning("TokenDocument#getActor has been deprecated. Please use the "
      + "TokenDocument#actor getter to retrieve the Actor instance that the TokenDocument represents, or use "
      + "TokenDocument#delta#apply to generate a new synthetic Actor instance.");
    return this.delta?.apply() ?? this.baseActor ?? null;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  get actorData() {
    foundry.utils.logCompatibilityWarning("You are accessing TokenDocument#actorData which is deprecated. Source data "
      + "may be retrieved via TokenDocument#delta but all modifications/access should be done via the synthetic Actor "
      + "at TokenDocument#actor if possible.", {since: 11, until: 13});
    return this.delta.toObject();
  }

  set actorData(actorData) {
    foundry.utils.logCompatibilityWarning("You are accessing TokenDocument#actorData which is deprecated. Source data "
      + "may be retrieved via TokenDocument#delta but all modifications/access should be done via the synthetic Actor "
      + "at TokenDocument#actor if possible.", {since: 11, until: 13});
    const id = this.delta.id;
    this.delta = new ActorDelta.implementation({...actorData, _id: id}, {parent: this});
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  async toggleActiveEffect(effectData, {overlay=false, active}={}) {
    foundry.utils.logCompatibilityWarning("TokenDocument#toggleActiveEffect is deprecated in favor of "
      + "Actor#toggleStatusEffect", {since: 12, until: 14});
    if ( !this.actor || !effectData.id ) return false;
    return !!(await this.actor.toggleStatusEffect(effectData.id, {active, overlay}));
  }
}

/* -------------------------------------------- */
/*  Proxy Prototype Token Methods               */
/* -------------------------------------------- */

foundry.data.PrototypeToken.prototype.getBarAttribute = TokenDocument.prototype.getBarAttribute;

/**
 * The client-side User document which extends the common BaseUser model.
 * Each User document contains UserData which defines its data schema.
 *
 * @extends foundry.documents.BaseUser
 * @mixes ClientDocumentMixin
 *
 * @see {@link Users}             The world-level collection of User documents
 * @see {@link foundry.applications.sheets.UserConfig} The User configuration application
 */
class User extends ClientDocumentMixin(foundry.documents.BaseUser) {

  /**
   * Track whether the user is currently active in the game
   * @type {boolean}
   */
  active = false;

  /**
   * Track references to the current set of Tokens which are targeted by the User
   * @type {Set<Token>}
   */
  targets = new UserTargets(this);

  /**
   * Track the ID of the Scene that is currently being viewed by the User
   * @type {string|null}
   */
  viewedScene = null;

  /**
   * A flag for whether the current User is a Trusted Player
   * @type {boolean}
   */
  get isTrusted() {
    return this.hasRole("TRUSTED");
  }

  /**
   * A flag for whether this User is the connected client
   * @type {boolean}
   */
  get isSelf() {
    return game.userId === this.id;
  }

  /* ---------------------------------------- */

  /** @inheritdoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.avatar = this.avatar || this.character?.img || CONST.DEFAULT_TOKEN;
    this.border = this.color.multiply(2);
  }

  /* ---------------------------------------- */
  /*  User Methods                            */
  /* ---------------------------------------- */

  /**
   * Assign a Macro to a numbered hotbar slot between 1 and 50
   * @param {Macro|null} macro      The Macro document to assign
   * @param {number|string} [slot]  A specific numbered hotbar slot to fill
   * @param {number} [fromSlot]     An optional origin slot from which the Macro is being shifted
   * @returns {Promise<User>}       A Promise which resolves once the User update is complete
   */
  async assignHotbarMacro(macro, slot, {fromSlot}={}) {
    if ( !(macro instanceof Macro) && (macro !== null) ) throw new Error("Invalid Macro provided");
    const hotbar = this.hotbar;

    // If a slot was not provided, get the first available slot
    if ( Number.isNumeric(slot) ) slot = Number(slot);
    else {
      for ( let i=1; i<=50; i++ ) {
        if ( !(i in hotbar ) ) {
          slot = i;
          break;
        }
      }
    }
    if ( !slot ) throw new Error("No available Hotbar slot exists");
    if ( slot < 1 || slot > 50 ) throw new Error("Invalid Hotbar slot requested");
    if ( macro && (hotbar[slot] === macro.id) ) return this;
    const current = hotbar[slot];

    // Update the macro for the new slot
    const update = foundry.utils.deepClone(hotbar);
    if ( macro ) update[slot] = macro.id;
    else delete update[slot];

    // Replace or remove the macro in the old slot
    if ( Number.isNumeric(fromSlot) && (fromSlot in hotbar) ) {
      if ( current ) update[fromSlot] = current;
      else delete update[fromSlot];
    }
    return this.update({hotbar: update}, {diff: false, recursive: false, noHook: true});
  }

  /* -------------------------------------------- */

  /**
   * Assign a specific boolean permission to this user.
   * Modifies the user permissions to grant or restrict access to a feature.
   *
   * @param {string} permission    The permission name from USER_PERMISSIONS
   * @param {boolean} allowed      Whether to allow or restrict the permission
   */
  assignPermission(permission, allowed) {
    if ( !game.user.isGM ) throw new Error(`You are not allowed to modify the permissions of User ${this.id}`);
    const permissions = {[permission]: allowed};
    return this.update({permissions});
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} PingData
   * @property {boolean} [pull=false]  Pulls all connected clients' views to the pinged coordinates.
   * @property {string} style          The ping style, see CONFIG.Canvas.pings.
   * @property {string} scene          The ID of the scene that was pinged.
   * @property {number} zoom           The zoom level at which the ping was made.
   */

  /**
   * @typedef {object} ActivityData
   * @property {string|null} [sceneId]           The ID of the scene that the user is viewing.
   * @property {{x: number, y: number}} [cursor] The position of the user's cursor.
   * @property {RulerData|null} [ruler]          The state of the user's ruler, if they are currently using one.
   * @property {string[]} [targets]              The IDs of the tokens the user has targeted in the currently viewed
   *                                             scene.
   * @property {boolean} [active]                Whether the user has an open WS connection to the server or not.
   * @property {PingData} [ping]                 Is the user emitting a ping at the cursor coordinates?
   * @property {AVSettingsData} [av]             The state of the user's AV settings.
   */

  /**
   * Submit User activity data to the server for broadcast to other players.
   * This type of data is transient, persisting only for the duration of the session and not saved to any database.
   * Activity data uses a volatile event to prevent unnecessary buffering if the client temporarily loses connection.
   * @param {ActivityData} activityData  An object of User activity data to submit to the server for broadcast.
   * @param {object} [options]
   * @param {boolean|undefined} [options.volatile]  If undefined, volatile is inferred from the activity data.
   */
  broadcastActivity(activityData={}, {volatile}={}) {
    volatile ??= !(("sceneId" in activityData)
      || (activityData.ruler === null)
      || ("targets" in activityData)
      || ("ping" in activityData)
      || ("av" in activityData));
    if ( volatile ) game.socket.volatile.emit("userActivity", this.id, activityData);
    else game.socket.emit("userActivity", this.id, activityData);
  }

  /* -------------------------------------------- */

  /**
   * Get an Array of Macro Documents on this User's Hotbar by page
   * @param {number} page     The hotbar page number
   * @returns {Array<{slot: number, macro: Macro|null}>}
   */
  getHotbarMacros(page=1) {
    const macros = Array.from({length: 50}, () => "");
    for ( let [k, v] of Object.entries(this.hotbar) ) {
      macros[parseInt(k)-1] = v;
    }
    const start = (page-1) * 10;
    return macros.slice(start, start+10).map((m, i) => {
      return {
        slot: start + i + 1,
        macro: m ? game.macros.get(m) : null
      };
    });
  }

  /* -------------------------------------------- */

  /**
   * Update the set of Token targets for the user given an array of provided Token ids.
   * @param {string[]} targetIds      An array of Token ids which represents the new target set
   */
  updateTokenTargets(targetIds=[]) {

    // Clear targets outside of the viewed scene
    if ( this.viewedScene !== canvas.scene.id ) {
      for ( let t of this.targets ) {
        t.setTarget(false, {user: this, releaseOthers: false, groupSelection: true});
      }
      return;
    }

    // Update within the viewed Scene
    const targets = new Set(targetIds);
    if ( this.targets.equals(targets) ) return;

    // Remove old targets
    for ( let t of this.targets ) {
      if ( !targets.has(t.id) ) t.setTarget(false, {user: this, releaseOthers: false, groupSelection: true});
    }

    // Add new targets
    for ( let id of targets ) {
      const token = canvas.tokens.get(id);
      if ( !token || this.targets.has(token) ) continue;
      token.setTarget(true, {user: this, releaseOthers: false, groupSelection: true});
    }
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc  */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // If the user role changed, we need to re-build the immutable User object
    if ( this._source.role !== this.role ) {
      const user = this.clone({}, {keepId: true});
      game.users.set(user.id, user);
      return user._onUpdate(changed, options, userId);
    }

    // If your own password or role changed - you must re-authenticate
    const isSelf = changed._id === game.userId;
    if ( isSelf && ["password", "role"].some(k => k in changed) ) return game.logOut();
    if ( !game.ready ) return;

    // User Color
    if ( "color" in changed ) {
      document.documentElement.style.setProperty(`--user-color-${this.id}`, this.color.css);
      if ( isSelf ) document.documentElement.style.setProperty("--user-color", this.color.css);
    }

    // Redraw Navigation
    if ( ["active", "character", "color", "role"].some(k => k in changed) ) {
      ui.nav?.render();
      ui.players?.render();
    }

    // Redraw Hotbar
    if ( isSelf && ("hotbar" in changed) ) ui.hotbar?.render();

    // Reconnect to Audio/Video conferencing, or re-render camera views
    const webRTCReconnect = ["permissions", "role"].some(k => k in changed);
    if ( webRTCReconnect && (changed._id === game.userId) ) {
      game.webrtc?.client.updateLocalStream().then(() => game.webrtc.render());
    } else if ( ["name", "avatar", "character"].some(k => k in changed) ) game.webrtc?.render();

    // Update Canvas
    if ( canvas.ready ) {

      // Redraw Cursor
      if ( "color" in changed ) {
        canvas.controls.drawCursor(this);
        const ruler = canvas.controls.getRulerForUser(this.id);
        if ( ruler ) ruler.color = Color.from(changed.color);
      }
      if ( "active" in changed ) canvas.controls.updateCursor(this, null);

      // Modify impersonated character
      if ( isSelf && ("character" in changed) ) {
        canvas.perception.initialize();
        canvas.tokens.cycleTokens(true, true);
      }
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc  */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( this.id === game.user.id ) return game.logOut();
  }
}

/**
 * The client-side Wall document which extends the common BaseWall document model.
 * @extends foundry.documents.BaseWall
 * @mixes ClientDocumentMixin
 *
 * @see {@link Scene}                     The Scene document type which contains Wall documents
 * @see {@link WallConfig}                The Wall configuration application
 */
class WallDocument extends CanvasDocumentMixin(foundry.documents.BaseWall) {}

const BLEND_MODES = {};

/**
 * A custom blend mode equation which chooses the maximum color from each channel within the stack.
 * @type {number[]}
 */
BLEND_MODES.MAX_COLOR = [
  WebGL2RenderingContext.ONE,
  WebGL2RenderingContext.ONE,
  WebGL2RenderingContext.ONE,
  WebGL2RenderingContext.ONE,
  WebGL2RenderingContext.MAX,
  WebGL2RenderingContext.MAX
];

/**
 * A custom blend mode equation which chooses the minimum color from each channel within the stack.
 * @type {number[]}
 */
BLEND_MODES.MIN_COLOR = [
  WebGL2RenderingContext.ONE,
  WebGL2RenderingContext.ONE,
  WebGL2RenderingContext.ONE,
  WebGL2RenderingContext.ONE,
  WebGL2RenderingContext.MIN,
  WebGL2RenderingContext.MAX
];

/**
 * A custom blend mode equation which chooses the minimum color for color channels and min alpha from alpha channel.
 * @type {number[]}
 */
BLEND_MODES.MIN_ALL = [
  WebGL2RenderingContext.ONE,
  WebGL2RenderingContext.ONE,
  WebGL2RenderingContext.ONE,
  WebGL2RenderingContext.ONE,
  WebGL2RenderingContext.MIN,
  WebGL2RenderingContext.MIN
];

/**
 * The virtual tabletop environment is implemented using a WebGL powered HTML 5 canvas using the powerful PIXI.js
 * library. The canvas is comprised by an ordered sequence of layers which define rendering groups and collections of
 * objects that are drawn on the canvas itself.
 *
 * ### Hook Events
 * {@link hookEvents.canvasConfig}
 * {@link hookEvents.canvasInit}
 * {@link hookEvents.canvasReady}
 * {@link hookEvents.canvasPan}
 * {@link hookEvents.canvasTearDown}
 *
 * @category - Canvas
 *
 * @example Canvas State
 * ```js
 * canvas.ready; // Is the canvas ready for use?
 * canvas.scene; // The currently viewed Scene document.
 * canvas.dimensions; // The dimensions of the current Scene.
 * ```
 * @example Canvas Methods
 * ```js
 * canvas.draw(); // Completely re-draw the game canvas (this is usually unnecessary).
 * canvas.pan(x, y, zoom); // Pan the canvas to new coordinates and scale.
 * canvas.recenter(); // Re-center the canvas on the currently controlled Token.
 * ```
 */
class Canvas {
  constructor() {
    Object.defineProperty(this, "edges", {value: new foundry.canvas.edges.CanvasEdges()});
    Object.defineProperty(this, "fog", {value: new CONFIG.Canvas.fogManager()});
    Object.defineProperty(this, "perception", {value: new PerceptionManager()});
  }

  /**
   * A set of blur filter instances which are modified by the zoom level and the "soft shadows" setting
   * @type {Set<PIXI.filters>}
   */
  blurFilters = new Set();

  /**
   * A reference to the MouseInteractionManager that is currently controlling pointer-based interaction, or null.
   * @type {MouseInteractionManager|null}
   */
  currentMouseManager = null;

  /**
   * Configure options passed to the texture loaded for the Scene.
   * This object can be configured during the canvasInit hook before textures have been loaded.
   * @type {{expireCache: boolean, additionalSources: string[]}}
   */
  loadTexturesOptions;

  /**
   * Configure options used by the visibility framework for special effects
   * This object can be configured during the canvasInit hook before visibility is initialized.
   * @type {{persistentVision: boolean}}
   */
  visibilityOptions;

  /**
   * Configure options passed to initialize blur for the Scene and override normal behavior.
   * This object can be configured during the canvasInit hook before blur is initialized.
   * @type {{enabled: boolean, blurClass: Class, strength: number, passes: number, kernels: number}}
   */
  blurOptions;

  /**
   * Configure the Textures to apply to the Scene.
   * Textures registered here will be automatically loaded as part of the TextureLoader.loadSceneTextures workflow.
   * Textures which need to be loaded should be configured during the "canvasInit" hook.
   * @type {{[background]: string, [foreground]: string, [fogOverlay]: string}}
   */
  sceneTextures = {};

  /**
   * Record framerate performance data.
   * @type {{average: number, values: number[], element: HTMLElement, render: number}}
   */
  fps = {
    average: 0,
    values: [],
    render: 0,
    element: document.getElementById("fps")
  };

  /**
   * The singleton interaction manager instance which handles mouse interaction on the Canvas.
   * @type {MouseInteractionManager}
   */
  mouseInteractionManager;

  /**
   * @typedef {Object} CanvasPerformanceSettings
   * @property {number} mode      The performance mode in CONST.CANVAS_PERFORMANCE_MODES
   * @property {string} mipmap    Whether to use mipmaps, "ON" or "OFF"
   * @property {boolean} msaa     Whether to apply MSAA at the overall canvas level
   * @property {boolean} smaa     Whether to apply SMAA at the overall canvas level
   * @property {number} fps       Maximum framerate which should be the render target
   * @property {boolean} tokenAnimation   Whether to display token movement animation
   * @property {boolean} lightAnimation   Whether to display light source animation
   * @property {boolean} lightSoftEdges   Whether to render soft edges for light sources
   */

  /**
   * Configured performance settings which affect the behavior of the Canvas and its renderer.
   * @type {CanvasPerformanceSettings}
   */
  performance;

  /**
   * @typedef {Object} CanvasSupportedComponents
   * @property {boolean} webGL2           Is WebGL2 supported?
   * @property {boolean} readPixelsRED    Is reading pixels in RED format supported?
   * @property {boolean} offscreenCanvas  Is the OffscreenCanvas supported?
   */

  /**
   * A list of supported webGL capabilities and limitations.
   * @type {CanvasSupportedComponents}
   */
  supported;

  /**
   * Is the photosensitive mode enabled?
   * @type {boolean}
   */
  photosensitiveMode;

  /**
   * The renderer screen dimensions.
   * @type {number[]}
   */
  screenDimensions = [0, 0];

  /**
   * A flag to indicate whether a new Scene is currently being drawn.
   * @type {boolean}
   */
  loading = false;

  /**
   * A promise that resolves when the canvas is first initialized and ready.
   * @type {Promise<void>|null}
   */
  initializing = null;

  /* -------------------------------------------- */

  /**
   * A throttled function that handles mouse moves.
   * @type {function()}
   */
  #throttleOnMouseMove = foundry.utils.throttle(this.#onMouseMove.bind(this), 100);

  /**
   * An internal reference to a Promise in-progress to draw the canvas.
   * @type {Promise<Canvas>}
   */
  #drawing = Promise.resolve(this);

  /* -------------------------------------------- */
  /*  Canvas Groups and Layers                    */
  /* -------------------------------------------- */

  /**
   * The singleton PIXI.Application instance rendered on the Canvas.
   * @type {PIXI.Application}
   */
  app;

  /**
   * The primary stage container of the PIXI.Application.
   * @type {PIXI.Container}
   */
  stage;

  /**
   * The rendered canvas group which render the environment canvas group and the interface canvas group.
   * @see environment
   * @see interface
   * @type {RenderedCanvasGroup}
   */
  rendered;

  /**
   * A singleton CanvasEdges instance.
   * @type {foundry.canvas.edges.CanvasEdges}
   */
  edges;

  /**
   * The singleton FogManager instance.
   * @type {FogManager}
   */
  fog;

  /**
   * A perception manager interface for batching lighting, sight, and sound updates.
   * @type {PerceptionManager}
   */
  perception;

  /**
   * The environment canvas group which render the primary canvas group and the effects canvas group.
   * @see primary
   * @see effects
   * @type {EnvironmentCanvasGroup}
   */
  environment;

  /**
   * The primary Canvas group which generally contains tangible physical objects which exist within the Scene.
   * This group is a {@link CachedContainer} which is rendered to the Scene as a {@link SpriteMesh}.
   * This allows the rendered result of the Primary Canvas Group to be affected by a {@link BaseSamplerShader}.
   * @type {PrimaryCanvasGroup}
   */
  primary;

  /**
   * The effects Canvas group which modifies the result of the {@link PrimaryCanvasGroup} by adding special effects.
   * This includes lighting, vision, fog of war and related animations.
   * @type {EffectsCanvasGroup}
   */
  effects;

  /**
   * The visibility Canvas group which handles the fog of war overlay by consolidating multiple render textures,
   * and applying a filter with special effects and blur.
   * @type {CanvasVisibility}
   */
  visibility;

  /**
   * The interface Canvas group which is rendered above other groups and contains all interactive elements.
   * The various {@link InteractionLayer} instances of the interface group provide different control sets for
   * interacting with different types of {@link Document}s which can be represented on the Canvas.
   * @type {InterfaceCanvasGroup}
   */
  interface;

  /**
   * The overlay Canvas group which is rendered above other groups and contains elements not bound to stage transform.
   * @type {OverlayCanvasGroup}
   */
  overlay;

  /**
   * The singleton HeadsUpDisplay container which overlays HTML rendering on top of this Canvas.
   * @type {HeadsUpDisplay}
   */
  hud;

  /**
   * Position of the mouse on stage.
   * @type {PIXI.Point}
   */
  mousePosition = new PIXI.Point();

  /**
   * The DragDrop instance which handles interactivity resulting from DragTransfer events.
   * @type {DragDrop}
   * @private
   */
  #dragDrop;

  /**
   * An object of data which caches data which should be persisted across re-draws of the game canvas.
   * @type {{scene: string, layer: string, controlledTokens: string[], targetedTokens: string[]}}
   * @private
   */
  #reload = {};

  /**
   * Track the last automatic pan time to throttle
   * @type {number}
   * @private
   */
  _panTime = 0;

  /* -------------------------------------------- */

  /**
   * Force snapping to grid vertices?
   * @type {boolean}
   */
  forceSnapVertices = false;

  /* -------------------------------------------- */
  /*  Properties and Attributes
  /* -------------------------------------------- */

  /**
   * A flag for whether the game Canvas is fully initialized and ready for additional content to be drawn.
   * @type {boolean}
   */
  get initialized() {
    return this.#initialized;
  }

  /** @ignore */
  #initialized = false;

  /* -------------------------------------------- */

  /**
   * A reference to the currently displayed Scene document, or null if the Canvas is currently blank.
   * @type {Scene|null}
   */
  get scene() {
    return this.#scene;
  }

  /** @ignore */
  #scene = null;

  /* -------------------------------------------- */

  /**
   * A SceneManager instance which adds behaviors to this Scene, or null if there is no manager.
   * @type {SceneManager|null}
   */
  get manager() {
    return this.#manager;
  }

  #manager = null;

  /* -------------------------------------------- */

  /**
   * @typedef {object} _CanvasDimensions
   * @property {PIXI.Rectangle} rect      The canvas rectangle.
   * @property {PIXI.Rectangle} sceneRect The scene rectangle.
   */

  /**
   * @typedef {SceneDimensions & _CanvasDimensions} CanvasDimensions
   */

  /**
   * The current pixel dimensions of the displayed Scene, or null if the Canvas is blank.
   * @type {Readonly<CanvasDimensions>|null}
   */
  get dimensions() {
    return this.#dimensions;
  }

  #dimensions = null;

  /* -------------------------------------------- */

  /**
   * A reference to the grid of the currently displayed Scene document, or null if the Canvas is currently blank.
   * @type {foundry.grid.BaseGrid|null}
   */
  get grid() {
    return this.scene?.grid ?? null;
  }

  /* -------------------------------------------- */

  /**
   * A flag for whether the game Canvas is ready to be used. False if the canvas is not yet drawn, true otherwise.
   * @type {boolean}
   */
  get ready() {
    return this.#ready;
  }

  /** @ignore */
  #ready = false;

  /* -------------------------------------------- */

  /**
   * The colors bound to this scene and handled by the color manager.
   * @type {Color}
   */
  get colors() {
    return this.environment.colors;
  }

  /* -------------------------------------------- */

  /**
   * Shortcut to get the masks container from HiddenCanvasGroup.
   * @type {PIXI.Container}
   */
  get masks() {
    return this.hidden.masks;
  }

  /* -------------------------------------------- */

  /**
   * The id of the currently displayed Scene.
   * @type {string|null}
   */
  get id() {
    return this.#scene?.id || null;
  }

  /* -------------------------------------------- */

  /**
   * A mapping of named CanvasLayer classes which defines the layers which comprise the Scene.
   * @type {Record<string, CanvasLayer>}
   */
  static get layers() {
    return CONFIG.Canvas.layers;
  }

  /* -------------------------------------------- */

  /**
   * An Array of all CanvasLayer instances which are active on the Canvas board
   * @type {CanvasLayer[]}
   */
  get layers() {
    const layers = [];
    for ( const [k, cfg] of Object.entries(CONFIG.Canvas.layers) ) {
      const l = this[cfg.group]?.[k] ?? this[k];
      if ( l instanceof CanvasLayer ) layers.push(l);
    }
    return layers;
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the active Canvas Layer
   * @type {CanvasLayer}
   */
  get activeLayer() {
    for ( const layer of this.layers ) {
      if ( layer.active ) return layer;
    }
    return null;
  }

  /* -------------------------------------------- */

  /**
   * The currently displayed darkness level, which may override the saved Scene value.
   * @type {number}
   */
  get darknessLevel() {
    return this.environment.darknessLevel;
  }

  /* -------------------------------------------- */
  /*  Initialization                              */
  /* -------------------------------------------- */

  /**
   * Initialize the Canvas by creating the HTML element and PIXI application.
   * This step should only ever be performed once per client session.
   * Subsequent requests to reset the canvas should go through Canvas#draw
   */
  initialize() {
    if ( this.#initialized ) throw new Error("The Canvas is already initialized and cannot be re-initialized");

    // If the game canvas is disabled by "no canvas" mode, we don't need to initialize anything
    if ( game.settings.get("core", "noCanvas") ) return;

    // Verify that WebGL is available
    Canvas.#configureWebGL();

    // Create the HTML Canvas element
    const canvas = Canvas.#createHTMLCanvas();

    // Configure canvas settings
    const config = Canvas.#configureCanvasSettings();

    // Create the PIXI Application
    this.#createApplication(canvas, config);

    // Configure the desired performance mode
    this._configurePerformanceMode();

    // Display any performance warnings which suggest that the created Application will not function well
    game.issues._detectWebGLIssues();

    // Activate drop handling
    this.#dragDrop = new DragDrop({ callbacks: { drop: this._onDrop.bind(this) } }).bind(canvas);

    // Create heads up display
    Object.defineProperty(this, "hud", {value: new HeadsUpDisplay(), writable: false});

    // Cache photosensitive mode
    Object.defineProperty(this, "photosensitiveMode", {
      value: game.settings.get("core", "photosensitiveMode"),
      writable: false
    });

    // Create groups
    this.#createGroups("stage", this.stage);

    // Update state flags
    this.#scene = null;
    this.#manager = null;
    this.#initialized = true;
    this.#ready = false;
  }

  /* -------------------------------------------- */

  /**
   * Configure the usage of WebGL for the PIXI.Application that will be created.
   * @throws an Error if WebGL is not supported by this browser environment.
   */
  static #configureWebGL() {
    if ( !PIXI.utils.isWebGLSupported() ) {
      const err = new Error(game.i18n.localize("ERROR.NoWebGL"));
      ui.notifications.error(err.message, {permanent: true});
      throw err;
    }
    PIXI.settings.PREFER_ENV = PIXI.ENV.WEBGL2;
  }

  /* -------------------------------------------- */

  /**
   * Create the Canvas element which will be the render target for the PIXI.Application instance.
   * Replace the template element which serves as a placeholder in the initially served HTML response.
   * @returns {HTMLCanvasElement}
   */
  static #createHTMLCanvas() {
    const board = document.getElementById("board");
    const canvas = document.createElement("canvas");
    canvas.id = "board";
    canvas.style.display = "none";
    board.replaceWith(canvas);
    return canvas;
  }

  /* -------------------------------------------- */

  /**
   * Configure the settings used to initialize the PIXI.Application instance.
   * @returns {object}    Options passed to the PIXI.Application constructor.
   */
  static #configureCanvasSettings() {
    const config = {
      width: window.innerWidth,
      height: window.innerHeight,
      transparent: false,
      resolution: game.settings.get("core", "pixelRatioResolutionScaling") ? window.devicePixelRatio : 1,
      autoDensity: true,
      antialias: false,  // Not needed because we use SmoothGraphics
      powerPreference: "high-performance" // Prefer high performance GPU for devices with dual graphics cards
    };
    Hooks.callAll("canvasConfig", config);
    return config;
  }

  /* -------------------------------------------- */

  /**
   * Initialize custom pixi plugins.
   */
  #initializePlugins() {
    BaseSamplerShader.registerPlugin({force: true});
    OccludableSamplerShader.registerPlugin();
    DepthSamplerShader.registerPlugin();

    // Configure TokenRing
    CONFIG.Token.ring.ringClass.initialize();
  }

  /* -------------------------------------------- */

  /**
   * Create the PIXI.Application and update references to the created app and stage.
   * @param {HTMLCanvasElement} canvas    The target canvas view element
   * @param {object} config               Desired PIXI.Application configuration options
   */
  #createApplication(canvas, config) {
    this.#initializePlugins();

    // Create the Application instance
    const app = new PIXI.Application({view: canvas, ...config});
    Object.defineProperty(this, "app", {value: app, writable: false});

    // Reference the Stage
    Object.defineProperty(this, "stage", {value: this.app.stage, writable: false});

    // Map all the custom blend modes
    this.#mapBlendModes();

    // Attach specific behaviors to the PIXI runners
    this.#attachToRunners();

    // Test the support of some GPU features
    const supported = this.#testSupport(app.renderer);
    Object.defineProperty(this, "supported", {
      value: Object.freeze(supported),
      writable: false,
      enumerable: true
    });

    // Additional PIXI configuration : Adding the FramebufferSnapshot to the canvas
    const snapshot = new FramebufferSnapshot();
    Object.defineProperty(this, "snapshot", {value: snapshot, writable: false});
  }

  /* -------------------------------------------- */

  /**
   * Attach specific behaviors to the PIXI runners.
   * - contextChange => Remap all the blend modes
   */
  #attachToRunners() {
    const contextChange = {
      contextChange: () => {
        console.debug(`${vtt} | Recovering from context loss.`);
        this.#mapBlendModes();
        this.hidden.invalidateMasks();
        this.effects.illumination.invalidateDarknessLevelContainer(true);
      }
    };
    this.app.renderer.runners.contextChange.add(contextChange);
  }

  /* -------------------------------------------- */

  /**
   * Map custom blend modes and premultiplied blend modes.
   */
  #mapBlendModes() {
    for ( let [k, v] of Object.entries(BLEND_MODES) ) {
      const pos = this.app.renderer.state.blendModes.push(v) - 1;
      PIXI.BLEND_MODES[k] = pos;
      PIXI.BLEND_MODES[pos] = k;
    }
    // Fix a PIXI bug with custom blend modes
    this.#mapPremultipliedBlendModes();
  }

  /* -------------------------------------------- */

  /**
   * Remap premultiplied blend modes/non premultiplied blend modes to fix PIXI bug with custom BM.
   */
  #mapPremultipliedBlendModes() {
    const pm = [];
    const npm = [];

    // Create the reference mapping
    for ( let i = 0; i < canvas.app.renderer.state.blendModes.length; i++ ) {
      pm[i] = i;
      npm[i] = i;
    }

    // Assign exceptions
    pm[PIXI.BLEND_MODES.NORMAL_NPM] = PIXI.BLEND_MODES.NORMAL;
    pm[PIXI.BLEND_MODES.ADD_NPM] = PIXI.BLEND_MODES.ADD;
    pm[PIXI.BLEND_MODES.SCREEN_NPM] = PIXI.BLEND_MODES.SCREEN;

    npm[PIXI.BLEND_MODES.NORMAL] = PIXI.BLEND_MODES.NORMAL_NPM;
    npm[PIXI.BLEND_MODES.ADD] = PIXI.BLEND_MODES.ADD_NPM;
    npm[PIXI.BLEND_MODES.SCREEN] = PIXI.BLEND_MODES.SCREEN_NPM;

    // Keep the reference to PIXI.utils.premultiplyBlendMode!
    // And recreate the blend modes mapping with the same object.
    PIXI.utils.premultiplyBlendMode.splice(0, PIXI.utils.premultiplyBlendMode.length);
    PIXI.utils.premultiplyBlendMode.push(npm);
    PIXI.utils.premultiplyBlendMode.push(pm);
  }

  /* -------------------------------------------- */

  /**
   * Initialize the group containers of the game Canvas.
   * @param {string} parentName
   * @param {PIXI.DisplayObject} parent
   */
  #createGroups(parentName, parent) {
    for ( const [name, config] of Object.entries(CONFIG.Canvas.groups) ) {
      if ( config.parent !== parentName ) continue;
      const group = new config.groupClass();
      Object.defineProperty(this, name, {value: group, writable: false});    // Reference on the Canvas
      Object.defineProperty(parent, name, {value: group, writable: false});  // Reference on the parent
      parent.addChild(group);
      this.#createGroups(name, group);                                       // Recursive
    }
  }

  /* -------------------------------------------- */

  /**
   * TODO: Add a quality parameter
   * Compute the blur parameters according to grid size and performance mode.
   * @param options            Blur options.
   * @private
   */
  _initializeBlur(options={}) {
    // Discard shared filters
    this.blurFilters.clear();

    // Compute base values from grid size
    const gridSize = this.scene.grid.size;
    const blurStrength = gridSize / 25;
    const blurFactor = gridSize / 100;

    // Lower stress for MEDIUM performance mode
    const level =
      Math.max(0, this.performance.mode - (this.performance.mode < CONST.CANVAS_PERFORMANCE_MODES.HIGH ? 1 : 0));
    const maxKernels = Math.max(5 + (level * 2), 5);
    const maxPass = 2 + (level * 2);

    // Compute blur parameters
    this.blur = new Proxy(Object.seal({
      enabled: options.enabled ?? this.performance.mode > CONST.CANVAS_PERFORMANCE_MODES.MED,
      blurClass: options.blurClass ?? AlphaBlurFilter,
      blurPassClass: options.blurPassClass ?? AlphaBlurFilterPass,
      strength: options.strength ?? blurStrength,
      passes: options.passes ?? Math.clamp(level + Math.floor(blurFactor), 2, maxPass),
      kernels: options.kernels
        ?? Math.clamp((2 * Math.ceil((1 + (2 * level) + Math.floor(blurFactor)) / 2)) - 1, 5, maxKernels)
    }), {
      set(obj, prop, value) {
        if ( prop !== "strength" ) throw new Error(`canvas.blur.${prop} is immutable`);
        const v = Reflect.set(obj, prop, value);
        canvas.updateBlur();
        return v;
      }
    });

    // Immediately update blur
    this.updateBlur();
  }

  /* -------------------------------------------- */

  /**
   * Configure performance settings for hte canvas application based on the selected performance mode.
   * @returns {CanvasPerformanceSettings}
   * @internal
   */
  _configurePerformanceMode() {
    const modes = CONST.CANVAS_PERFORMANCE_MODES;

    // Get client settings
    let mode = game.settings.get("core", "performanceMode");
    const fps = game.settings.get("core", "maxFPS");
    const mip = game.settings.get("core", "mipmap");

    // Deprecation shim for textures
    const gl = this.app.renderer.context.gl;
    const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);

    // Configure default performance mode if one is not set
    if ( mode === null ) {
      if ( maxTextureSize <= Math.pow(2, 12) ) mode = CONST.CANVAS_PERFORMANCE_MODES.LOW;
      else if ( maxTextureSize <= Math.pow(2, 13) ) mode = CONST.CANVAS_PERFORMANCE_MODES.MED;
      else mode = CONST.CANVAS_PERFORMANCE_MODES.HIGH;
      game.settings.storage.get("client").setItem("core.performanceMode", String(mode));
    }

    // Construct performance settings object
    const settings = {
      mode: mode,
      mipmap: mip ? "ON" : "OFF",
      msaa: false,
      smaa: false,
      fps: Math.clamp(fps, 0, 60),
      tokenAnimation: true,
      lightAnimation: true,
      lightSoftEdges: false
    };

    // Low settings
    if ( mode >= modes.LOW ) {
      settings.tokenAnimation = false;
      settings.lightAnimation = false;
    }

    // Medium settings
    if ( mode >= modes.MED ) {
      settings.lightSoftEdges = true;
      settings.smaa = true;
    }

    // Max settings
    if ( mode === modes.MAX ) {
      if ( settings.fps === 60 ) settings.fps = 0;
    }

    // Configure performance settings
    PIXI.BaseTexture.defaultOptions.mipmap = PIXI.MIPMAP_MODES[settings.mipmap];
    // Use the resolution and multisample of the current render target for filters by default
    PIXI.Filter.defaultResolution = null;
    PIXI.Filter.defaultMultisample = null;
    this.app.ticker.maxFPS = PIXI.Ticker.shared.maxFPS = PIXI.Ticker.system.maxFPS = settings.fps;
    return this.performance = settings;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Draw the game canvas.
   * @param {Scene} [scene]         A specific Scene document to render on the Canvas
   * @returns {Promise<Canvas>}     A Promise which resolves once the Canvas is fully drawn
   */
  async draw(scene) {
    this.#drawing = this.#drawing.finally(this.#draw.bind(this, scene));
    await this.#drawing;
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Draw the game canvas.
   * This method is wrapped by a promise that enqueues multiple draw requests.
   * @param {Scene} [scene]         A specific Scene document to render on the Canvas
   * @returns {Promise<void>}
   */
  async #draw(scene) {

    // If the canvas had not yet been initialized, we have done something out of order
    if ( !this.#initialized ) {
      throw new Error("You may not call Canvas#draw before Canvas#initialize");
    }

    // Identify the Scene which should be drawn
    if ( scene === undefined ) scene = game.scenes.current;
    if ( !((scene instanceof Scene) || (scene === null)) ) {
      throw new Error("You must provide a Scene Document to draw the Canvas.");
    }

    // Assign status flags
    const wasReady = this.#ready;
    this.#ready = false;
    this.stage.visible = false;
    this.loading = true;

    // Tear down any existing scene
    if ( wasReady ) {
      try {
        await this.tearDown();
      } catch(err) {
        err.message = `Encountered an error while tearing down the previous scene: ${err.message}`;
        logger.error(err);
      }
    }

    // Record Scene changes
    if ( this.#scene && (scene !== this.#scene) ) {
      this.#scene._view = false;
      if ( game.user.viewedScene === this.#scene.id ) game.user.viewedScene = null;
    }
    this.#scene = scene;

    // Draw a blank canvas
    if ( this.#scene === null ) return this.#drawBlank();

    // Configure Scene dimensions
    const {rect, sceneRect, ...sceneDimensions} = scene.getDimensions();
    this.#dimensions = Object.assign(sceneDimensions, {
      rect: new PIXI.Rectangle(rect.x, rect.y, rect.width, rect.height),
      sceneRect: new PIXI.Rectangle(sceneRect.x, sceneRect.y, sceneRect.width, sceneRect.height)
    });
    canvas.app.view.style.display = "block";
    document.documentElement.style.setProperty("--gridSize", `${this.dimensions.size}px`);

    // Configure a SceneManager instance
    this.#manager = Canvas.getSceneManager(this.#scene);

    // Initialize the basis transcoder
    if ( CONFIG.Canvas.transcoders.basis ) await TextureLoader.initializeBasisTranscoder();

    // Call Canvas initialization hooks
    this.loadTexturesOptions = {expireCache: true, additionalSources: []};
    this.visibilityOptions = {persistentVision: false};
    console.log(`${vtt} | Drawing game canvas for scene ${this.#scene.name}`);
    await this.#callManagerEvent("_onInit");
    await this.#callManagerEvent("_registerHooks");
    Hooks.callAll("canvasInit", this);

    // Configure attributes of the Stage
    this.stage.position.set(window.innerWidth / 2, window.innerHeight / 2);
    this.stage.hitArea = {contains: () => true};
    this.stage.eventMode = "static";
    this.stage.sortableChildren = true;

    // Initialize the camera view position (although the canvas is hidden)
    this.initializeCanvasPosition();

    // Initialize blur parameters
    this._initializeBlur(this.blurOptions);

    // Load required textures
    try {
      await TextureLoader.loadSceneTextures(this.#scene, this.loadTexturesOptions);
    } catch(err) {
      Hooks.onError("Canvas#draw", err, {
        msg: `Texture loading failed: ${err.message}`,
        log: "error",
        notify: "error"
      });
      this.loading = false;
      return;
    }

    // Configure the SMAA filter
    if ( this.performance.smaa ) this.stage.filters = [new foundry.canvas.SMAAFilter()];

    // Configure TokenRing
    CONFIG.Token.ring.ringClass.createAssetsUVs();

    // Activate ticker render workflows
    this.#activateTicker();

    // Draw canvas groups
    await this.#callManagerEvent("_onDraw");
    Hooks.callAll("canvasDraw", this);
    for ( const name of Object.keys(CONFIG.Canvas.groups) ) {
      const group = this[name];
      try {
        await group.draw();
      } catch(err) {
        Hooks.onError("Canvas#draw", err, {
          msg: `Failed drawing ${name} canvas group: ${err.message}`,
          log: "error",
          notify: "error"
        });
        this.loading = false;
        return;
      }
    }

    // Mask primary and effects layers by the overall canvas
    const cr = canvas.dimensions.rect;
    this.masks.canvas.clear().beginFill(0xFFFFFF, 1.0).drawRect(cr.x, cr.y, cr.width, cr.height).endFill();
    this.primary.sprite.mask = this.primary.mask = this.effects.mask = this.interface.grid.mask =
      this.interface.templates.mask = this.masks.canvas;

    // Compute the scene scissor mask
    const sr = canvas.dimensions.sceneRect;
    this.masks.scene.clear().beginFill(0xFFFFFF, 1.0).drawRect(sr.x, sr.y, sr.width, sr.height).endFill();

    // Initialize starting conditions
    await this.#initialize();

    this.#scene._view = true;
    this.stage.visible = true;
    await this.#callManagerEvent("_onReady");
    Hooks.call("canvasReady", this);

    // Record that loading was complete and return
    this.loading = false;

    // Trigger Region status events
    await this.#handleRegionBehaviorStatusEvents(true);

    MouseInteractionManager.emulateMoveEvent();
  }

  /* -------------------------------------------- */

  /**
   * When re-drawing the canvas, first tear down or discontinue some existing processes
   * @returns {Promise<void>}
   */
  async tearDown() {
    this.stage.visible = false;
    this.stage.filters = null;
    this.sceneTextures = {};
    this.blurOptions = undefined;

    // Track current data which should be restored on draw
    this.#reload = {
      scene: this.#scene.id,
      layer: this.activeLayer?.options.name,
      controlledTokens: this.tokens.controlled.map(t => t.id),
      targetedTokens: Array.from(game.user.targets).map(t => t.id)
    };

    // Deactivate ticker workflows
    this.#deactivateTicker();
    this.deactivateFPSMeter();

    // Deactivate every layer before teardown
    for ( let l of this.layers.reverse() ) {
      if ( l instanceof InteractionLayer ) l.deactivate();
    }

    // Trigger Region status events
    await this.#handleRegionBehaviorStatusEvents(false);

    // Call tear-down hooks
    await this.#callManagerEvent("_deactivateHooks");
    await this.#callManagerEvent("_onTearDown");
    Hooks.callAll("canvasTearDown", this);

    // Tear down groups
    for ( const name of Object.keys(CONFIG.Canvas.groups).reverse() ) {
      const group = this[name];
      await group.tearDown();
    }

    // Tear down every layer
    await this.effects.tearDown();
    for ( let l of this.layers.reverse() ) {
      await l.tearDown();
    }

    // Clear edges
    this.edges.clear();

    // Discard shared filters
    this.blurFilters.clear();

    // Create a new event boundary for the stage
    this.app.renderer.events.rootBoundary = new PIXI.EventBoundary(this.stage);
    MouseInteractionManager.emulateMoveEvent();
  }

  /* -------------------------------------------- */

  /**
   * Handle Region BEHAVIOR_STATUS events that are triggered when the Scene is (un)viewed.
   * @param {boolean} viewed    Is the scene viewed or not?
   */
  async #handleRegionBehaviorStatusEvents(viewed) {
    const results = await Promise.allSettled(this.scene.regions.map(region => region._handleEvent({
      name: CONST.REGION_EVENTS.BEHAVIOR_STATUS,
      data: {viewed},
      region,
      user: game.user
    })));
    for ( const result of results ) {
      if ( result.status === "rejected" ) console.error(result.reason);
    }
  }

  /* -------------------------------------------- */

  /**
   * Create a SceneManager instance used for this Scene, if any.
   * @param {Scene} scene
   * @returns {foundry.canvas.SceneManager|null}
   * @internal
   */
  static getSceneManager(scene) {
    const managerCls = CONFIG.Canvas.managedScenes[scene.id];
    return managerCls ? new managerCls(scene) : null;
  }

  /* -------------------------------------------- */

  /**
   * A special workflow to perform when rendering a blank Canvas with no active Scene.
   */
  #drawBlank() {
    console.log(`${vtt} | Skipping game canvas - no active scene.`);
    canvas.app.view.style.display = "none";
    ui.controls.render();
    this.loading = this.#ready = false;
    this.#manager = null;
    this.#dimensions = null;
    MouseInteractionManager.emulateMoveEvent();
  }

  /* -------------------------------------------- */

  /**
   * Get the value of a GL parameter
   * @param {string} parameter  The GL parameter to retrieve
   * @returns {*}               The GL parameter value
   */
  getGLParameter(parameter) {
    const gl = this.app.renderer.context.gl;
    return gl.getParameter(gl[parameter]);
  }

  /* -------------------------------------------- */

  /**
   * Once the canvas is drawn, initialize control, visibility, and audio states
   * @returns {Promise<void>}
   */
  async #initialize() {
    this.#ready = true;

    // Clear the set of targeted Tokens for the current user
    game.user.targets.clear();

    // Render the HUD layer
    this.hud.render(true);

    // Initialize canvas conditions
    this.#initializeCanvasLayer();
    this.#initializeTokenControl();
    this._onResize();
    this.#reload = {};

    // Initialize edges and perception
    this.edges.initialize();
    this.perception.initialize();

    // Broadcast user presence in the Scene and request user activity data
    game.user.viewedScene = this.#scene.id;
    game.user.broadcastActivity({sceneId: this.#scene.id, cursor: null, ruler: null, targets: []});
    game.socket.emit("getUserActivity");

    // Activate user interaction
    this.#addListeners();

    // Call PCO sorting
    canvas.primary.sortChildren();
  }

  /* -------------------------------------------- */

  /**
   * Initialize the starting view of the canvas stage
   * If we are re-drawing a scene which was previously rendered, restore the prior view position
   * Otherwise set the view to the top-left corner of the scene at standard scale
   */
  initializeCanvasPosition() {

    // If we are re-drawing a Scene that was already visited, use it's cached view position
    let position = this.#scene._viewPosition;

    // Use a saved position, or determine the default view based on the scene size
    if ( foundry.utils.isEmpty(position) ) {
      let {x, y, scale} = this.#scene.initial;
      const r = this.dimensions.rect;
      x ??= (r.right / 2);
      y ??= (r.bottom / 2);
      scale ??= Math.clamp(Math.min(window.innerHeight / r.height, window.innerWidth / r.width), 0.25, 3);
      position = {x, y, scale};
    }

    // Pan to the initial view
    this.pan(position);
  }

  /* -------------------------------------------- */

  /**
   * Initialize a CanvasLayer in the activation state
   */
  #initializeCanvasLayer() {
    const layer = this[this.#reload.layer] ?? this.tokens;
    layer.activate();
  }

  /* -------------------------------------------- */

  /**
   * Initialize a token or set of tokens which should be controlled.
   * Restore controlled and targeted tokens from before the re-draw.
   */
  #initializeTokenControl() {
    let panToken = null;
    let controlledTokens = [];
    let targetedTokens = [];

    // Initial tokens based on reload data
    let isReload = this.#reload.scene === this.#scene.id;
    if ( isReload ) {
      controlledTokens = this.#reload.controlledTokens.map(id => canvas.tokens.get(id));
      targetedTokens = this.#reload.targetedTokens.map(id => canvas.tokens.get(id));
    }

    // Initialize tokens based on player character
    else if ( !game.user.isGM ) {
      controlledTokens = game.user.character?.getActiveTokens() || [];
      if (!controlledTokens.length) {
        controlledTokens = canvas.tokens.placeables.filter(t => t.actor?.testUserPermission(game.user, "OWNER"));
      }
      if (!controlledTokens.length) {
        const observed = canvas.tokens.placeables.filter(t => t.actor?.testUserPermission(game.user, "OBSERVER"));
        panToken = observed.shift() || null;
      }
    }

    // Initialize Token Control
    for ( let token of controlledTokens ) {
      if ( !panToken ) panToken = token;
      token?.control({releaseOthers: false});
    }

    // Display a warning if the player has no vision tokens in a visibility-restricted scene
    if ( !game.user.isGM && this.#scene.tokenVision && !canvas.effects.visionSources.size ) {
      ui.notifications.warn("TOKEN.WarningNoVision", {localize: true});
    }

    // Initialize Token targets
    for ( const token of targetedTokens ) {
      token?.setTarget(true, {releaseOthers: false, groupSelection: true});
    }

    // Pan camera to controlled token
    if ( panToken && !isReload ) this.pan({x: panToken.center.x, y: panToken.center.y, duration: 250});
  }

  /* -------------------------------------------- */

  /**
   * Safely call a function of the SceneManager instance, catching and logging any errors.
   * @param {string} fnName       The name of the manager function to invoke
   * @returns {Promise<void>}
   */
  async #callManagerEvent(fnName) {
    if ( !this.#manager ) return;
    const fn = this.#manager[fnName];
    try {
      if ( !(fn instanceof Function) ) {
        console.error(`Invalid SceneManager function name "${fnName}"`);
        return;
      }
      await fn.call(this.#manager);
    } catch(err) {
      err.message = `${this.#manager.constructor.name}#${fnName} failed with error: ${err.message}`;
      console.error(err);
    }
  }

  /* -------------------------------------------- */

  /**
   * Given an embedded object name, get the canvas layer for that object
   * @param {string} embeddedName
   * @returns {PlaceablesLayer|null}
   */
  getLayerByEmbeddedName(embeddedName) {
    return {
      AmbientLight: this.lighting,
      AmbientSound: this.sounds,
      Drawing: this.drawings,
      MeasuredTemplate: this.templates,
      Note: this.notes,
      Region: this.regions,
      Tile: this.tiles,
      Token: this.tokens,
      Wall: this.walls
    }[embeddedName] || null;
  }

  /* -------------------------------------------- */

  /**
   * Get the InteractionLayer of the canvas which manages Documents of a certain collection within the Scene.
   * @param {string} collectionName     The collection name
   * @returns {PlaceablesLayer}         The canvas layer
   */
  getCollectionLayer(collectionName) {
    return {
      drawings: this.drawings,
      lights: this.lighting,
      notes: this.notes,
      regions: this.regions,
      sounds: this.sounds,
      templates: this.templates,
      tiles: this.tiles,
      tokens: this.tokens,
      walls: this.walls
    }[collectionName];
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Activate framerate tracking by adding an HTML element to the display and refreshing it every frame.
   */
  activateFPSMeter() {
    this.deactivateFPSMeter();
    if ( !this.#ready ) return;
    this.fps.element.style.display = "block";
    this.app.ticker.add(this.#measureFPS, this, PIXI.UPDATE_PRIORITY.LOW);
  }

  /* -------------------------------------------- */

  /**
   * Deactivate framerate tracking by canceling ticker updates and removing the HTML element.
   */
  deactivateFPSMeter() {
    this.app.ticker.remove(this.#measureFPS, this);
    this.fps.element.style.display = "none";
  }

  /* -------------------------------------------- */

  /**
   * Measure average framerate per second over the past 30 frames
   */
  #measureFPS() {
    const lastTime = this.app.ticker.lastTime;

    // Push fps values every frame
    this.fps.values.push(1000 / this.app.ticker.elapsedMS);
    if ( this.fps.values.length > 60 ) this.fps.values.shift();

    // Do some computations and rendering occasionally
    if ( (lastTime - this.fps.render) < 250 ) return;
    if ( !this.fps.element ) return;

    // Compute average fps
    const total = this.fps.values.reduce((fps, total) => total + fps, 0);
    this.fps.average = (total / this.fps.values.length);

    // Render it
    this.fps.element.innerHTML = `<label>FPS:</label> <span>${this.fps.average.toFixed(2)}</span>`;
    this.fps.render = lastTime;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {Object} CanvasViewPosition
   * @property {number|null} x      The x-coordinate which becomes stage.pivot.x
   * @property {number|null} y      The y-coordinate which becomes stage.pivot.y
   * @property {number|null} scale  The zoom level up to CONFIG.Canvas.maxZoom which becomes stage.scale.x and y
   */

  /**
   * Pan the canvas to a certain {x,y} coordinate and a certain zoom level
   * @param {CanvasViewPosition} position     The canvas position to pan to
   */
  pan({x=null, y=null, scale=null}={}) {

    // Constrain the resulting canvas view
    const constrained = this._constrainView({x, y, scale});
    const scaleChange = constrained.scale !== this.stage.scale.x;

    // Set the pivot point
    this.stage.pivot.set(constrained.x, constrained.y);

    // Set the zoom level
    if ( scaleChange ) {
      this.stage.scale.set(constrained.scale, constrained.scale);
      this.updateBlur();
    }

    // Update the scene tracked position
    this.scene._viewPosition = constrained;

    // Call hooks
    Hooks.callAll("canvasPan", this, constrained);

    // Update controls
    this.controls._onCanvasPan();

    // Align the HUD
    this.hud.align();

    // Invalidate cached containers
    this.hidden.invalidateMasks();
    this.effects.illumination.invalidateDarknessLevelContainer();

    // Emulate mouse event to update the hover states
    MouseInteractionManager.emulateMoveEvent();
  }

  /* -------------------------------------------- */


  /**
   * Animate panning the canvas to a certain destination coordinate and zoom scale
   * Customize the animation speed with additional options
   * Returns a Promise which is resolved once the animation has completed
   *
   * @param {CanvasViewPosition} view     The desired view parameters
   * @param {number} [view.duration=250]  The total duration of the animation in milliseconds; used if speed is not set
   * @param {number} [view.speed]         The speed of animation in pixels per second; overrides duration if set
   * @param {Function} [view.easing]      An easing function passed to CanvasAnimation animate
   * @returns {Promise}                   A Promise which resolves once the animation has been completed
   */
  async animatePan({x, y, scale, duration=250, speed, easing}={}) {

    // Determine the animation duration to reach the target
    if ( speed ) {
      let ray = new Ray(this.stage.pivot, {x, y});
      duration = Math.round(ray.distance * 1000 / speed);
    }

    // Constrain the resulting dimensions and construct animation attributes
    const position = {...this.scene._viewPosition};
    const constrained = this._constrainView({x, y, scale});

    // Trigger the animation function
    return CanvasAnimation.animate([
      {parent: position, attribute: "x", to: constrained.x},
      {parent: position, attribute: "y", to: constrained.y},
      {parent: position, attribute: "scale", to: constrained.scale}
    ], {
      name: "canvas.animatePan",
      duration: duration,
      easing: easing ?? CanvasAnimation.easeInOutCosine,
      ontick: () => this.pan(position)
    });
  }

  /* -------------------------------------------- */

  /**
   * Recenter the canvas with a pan animation that ends in the center of the canvas rectangle.
   * @param {CanvasViewPosition} initial    A desired initial position from which to begin the animation
   * @returns {Promise<void>}               A Promise which resolves once the animation has been completed
   */
  async recenter(initial) {
    if ( initial ) this.pan(initial);
    const r = this.dimensions.sceneRect;
    return this.animatePan({
      x: r.x + (window.innerWidth / 2),
      y: r.y + (window.innerHeight / 2),
      duration: 250
    });
  }

  /* -------------------------------------------- */

  /**
   * Highlight objects on any layers which are visible
   * @param {boolean} active
   */
  highlightObjects(active) {
    if ( !this.#ready ) return;
    for ( let layer of this.layers ) {
      if ( !layer.objects || !layer.interactiveChildren ) continue;
      layer.highlightObjects = active;
      for ( let o of layer.placeables ) {
        o.renderFlags.set({refreshState: true});
      }
    }
    if ( canvas.tokens.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.HIGHLIGHTED ) {
      canvas.perception.update({refreshOcclusion: true});
    }
    /** @see hookEvents.highlightObjects */
    Hooks.callAll("highlightObjects", active);
  }

  /* -------------------------------------------- */

  /**
   * Displays a Ping both locally and on other connected client, following these rules:
   * 1) Displays on the current canvas Scene
   * 2) If ALT is held, becomes an ALERT ping
   * 3) Else if the user is GM and SHIFT is held, becomes a PULL ping
   * 4) Else is a PULSE ping
   * @param {Point} origin                  Point to display Ping at
   * @param {PingOptions} [options]         Additional options to configure how the ping is drawn.
   * @returns {Promise<boolean>}
   */
  async ping(origin, options) {
    // Don't allow pinging outside of the canvas bounds
    if ( !this.dimensions.rect.contains(origin.x, origin.y) ) return false;
    // Configure the ping to be dispatched
    const types = CONFIG.Canvas.pings.types;
    const isPull = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.SHIFT);
    const isAlert = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT);
    let style = types.PULSE;
    if ( isPull ) style = types.PULL;
    else if ( isAlert ) style = types.ALERT;
    let ping = {scene: this.scene?.id, pull: isPull, style, zoom: canvas.stage.scale.x};
    ping = foundry.utils.mergeObject(ping, options);

    // Broadcast the ping to other connected clients
    /** @type ActivityData */
    const activity = {cursor: origin, ping};
    game.user.broadcastActivity(activity);

    // Display the ping locally
    return this.controls.handlePing(game.user, origin, ping);
  }

  /* -------------------------------------------- */

  /**
   * Get the constrained zoom scale parameter which is allowed by the maxZoom parameter
   * @param {CanvasViewPosition} position   The unconstrained position
   * @returns {CanvasViewPosition}          The constrained position
   * @internal
   */
  _constrainView({x, y, scale}) {
    if ( !Number.isNumeric(x) ) x = this.stage.pivot.x;
    if ( !Number.isNumeric(y) ) y = this.stage.pivot.y;
    if ( !Number.isNumeric(scale) ) scale = this.stage.scale.x;
    const d = canvas.dimensions;

    // Constrain the scale to the maximum zoom level
    const maxScale = CONFIG.Canvas.maxZoom;
    const minScale = 1 / Math.max(d.width / window.innerWidth, d.height / window.innerHeight, maxScale);
    scale = Math.clamp(scale, minScale, maxScale);

    // Constrain the pivot point using the new scale
    const padX = 0.4 * (window.innerWidth / scale);
    const padY = 0.4 * (window.innerHeight / scale);
    x = Math.clamp(x, -padX, d.width + padX);
    y = Math.clamp(y, -padY, d.height + padY);

    // Return the constrained view dimensions
    return {x, y, scale};
  }

  /* -------------------------------------------- */

  /**
   * Create a BlurFilter instance and register it to the array for updates when the zoom level changes.
   * @param {number} blurStrength         The desired blur strength to use for this filter
   * @param {number} blurQuality          The desired quality to use for this filter
   * @returns {PIXI.BlurFilter}
   */
  createBlurFilter(blurStrength, blurQuality=CONFIG.Canvas.blurQuality) {
    const configuredStrength = blurStrength ?? this.blur.strength ?? CONFIG.Canvas.blurStrength;
    const f = new PIXI.BlurFilter(configuredStrength, blurQuality);
    f._configuredStrength = configuredStrength;
    this.addBlurFilter(f);
    return f;
  }

  /* -------------------------------------------- */

  /**
   * Add a filter to the blur filter list. The filter must have the blur property
   * @param {PIXI.BlurFilter} filter    The Filter instance to add
   * @returns {PIXI.BlurFilter}         The added filter for method chaining
   */
  addBlurFilter(filter) {
    if ( filter.blur === undefined ) return;
    filter.blur = (filter._configuredStrength ?? this.blur.strength ?? CONFIG.Canvas.blurStrength) * this.stage.scale.x;
    this.blurFilters.add(filter); // Save initial blur of the filter in the set
    return filter;
  }

  /* -------------------------------------------- */

  /**
   * Update the blur strength depending on the scale of the canvas stage.
   * This number is zero if "soft shadows" are disabled
   * @param {number} [strength]      Optional blur strength to apply
   * @private
   */
  updateBlur(strength) {
    for ( const filter of this.blurFilters ) {
      filter.blur = (strength ?? filter._configuredStrength ?? this.blur.strength ?? CONFIG.Canvas.blurStrength)
        * this.stage.scale.x;
    }
  }

  /* -------------------------------------------- */

  /**
   * Convert canvas coordinates to the client's viewport.
   * @param {Point} origin  The canvas coordinates.
   * @returns {Point}       The corresponding coordinates relative to the client's viewport.
   */
  clientCoordinatesFromCanvas(origin) {
    const point = {x: origin.x, y: origin.y};
    return this.stage.worldTransform.apply(point, point);
  }

  /* -------------------------------------------- */

  /**
   * Convert client viewport coordinates to canvas coordinates.
   * @param {Point} origin  The client coordinates.
   * @returns {Point}       The corresponding canvas coordinates.
   */
  canvasCoordinatesFromClient(origin) {
    const point = {x: origin.x, y: origin.y};
    return this.stage.worldTransform.applyInverse(point, point);
  }

  /* -------------------------------------------- */

  /**
   * Determine whether given canvas coordinates are off-screen.
   * @param {Point} position  The canvas coordinates.
   * @returns {boolean}       Is the coordinate outside the screen bounds?
   */
  isOffscreen(position) {
    const { clientWidth, clientHeight } = document.documentElement;
    const { x, y } = this.clientCoordinatesFromCanvas(position);
    return (x < 0) || (y < 0) || (x >= clientWidth) || (y >= clientHeight);
  }


  /* -------------------------------------------- */

  /**
   * Remove all children of the display object and call one cleaning method:
   * clean first, then tearDown, and destroy if no cleaning method is found.
   * @param {PIXI.DisplayObject} displayObject  The display object to clean.
   * @param {boolean} destroy                   If textures should be destroyed.
   */
  static clearContainer(displayObject, destroy=true) {
    const children = displayObject.removeChildren();
    for ( const child of children ) {
      if ( child.clear ) child.clear(destroy);
      else if ( child.tearDown ) child.tearDown();
      else child.destroy(destroy);
    }
  }

  /* -------------------------------------------- */

  /**
   * Get a texture with the required configuration and clear color.
   * @param {object} options
   * @param {number[]} [options.clearColor]           The clear color to use for this texture. Transparent by default.
   * @param {object} [options.textureConfiguration]   The render texture configuration.
   * @returns {PIXI.RenderTexture}
   */
  static getRenderTexture({clearColor, textureConfiguration}={}) {
    const texture = PIXI.RenderTexture.create(textureConfiguration);
    if ( clearColor ) texture.baseTexture.clearColor = clearColor;
    return texture;
  }

  /* -------------------------------------------- */
  /* Event Handlers
  /* -------------------------------------------- */

  /**
   * Attach event listeners to the game canvas to handle click and interaction events
   */
  #addListeners() {

    // Remove all existing listeners
    this.stage.removeAllListeners();

    // Define callback functions for mouse interaction events
    const callbacks = {
      clickLeft: this.#onClickLeft.bind(this),
      clickLeft2: this.#onClickLeft2.bind(this),
      clickRight: this.#onClickRight.bind(this),
      clickRight2: this.#onClickRight2.bind(this),
      dragLeftStart: this.#onDragLeftStart.bind(this),
      dragLeftMove: this.#onDragLeftMove.bind(this),
      dragLeftDrop: this.#onDragLeftDrop.bind(this),
      dragLeftCancel: this.#onDragLeftCancel.bind(this),
      dragRightStart: this._onDragRightStart.bind(this),
      dragRightMove: this._onDragRightMove.bind(this),
      dragRightDrop: this._onDragRightDrop.bind(this),
      dragRightCancel: this._onDragRightCancel.bind(this),
      longPress: this.#onLongPress.bind(this)
    };

    // Create and activate the interaction manager
    const permissions = {
      clickRight2: false,
      dragLeftStart: this.#canDragLeftStart.bind(this)
    };
    const mgr = new MouseInteractionManager(this.stage, this.stage, permissions, callbacks);
    this.mouseInteractionManager = mgr.activate();

    // Debug average FPS
    if ( game.settings.get("core", "fpsMeter") ) this.activateFPSMeter();
    this.dt = 0;

    // Add a listener for cursor movement
    this.stage.on("pointermove", event => {
      event.getLocalPosition(this.stage, this.mousePosition);
      this.#throttleOnMouseMove();
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse movement on the game canvas.
   */
  #onMouseMove() {
    this.controls._onMouseMove();
    this.sounds._onMouseMove();
    this.primary._onMouseMove();
  }

  /* -------------------------------------------- */

  /**
   * Handle left mouse-click events occurring on the Canvas.
   * @see {MouseInteractionManager##handleClickLeft}
   * @param {PIXI.FederatedEvent} event
   */
  #onClickLeft(event) {
    const layer = this.activeLayer;
    if ( layer instanceof InteractionLayer ) return layer._onClickLeft(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle double left-click events occurring on the Canvas.
   * @see {MouseInteractionManager##handleClickLeft2}
   * @param {PIXI.FederatedEvent} event
   */
  #onClickLeft2(event) {
    const layer = this.activeLayer;
    if ( layer instanceof InteractionLayer ) return layer._onClickLeft2(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle long press events occurring on the Canvas.
   * @see {MouseInteractionManager##handleLongPress}
   * @param {PIXI.FederatedEvent}   event   The triggering canvas interaction event.
   * @param {PIXI.Point}            origin  The local canvas coordinates of the mousepress.
   */
  #onLongPress(event, origin) {
    canvas.controls._onLongPress(event, origin);
  }

  /* -------------------------------------------- */

  /**
   * Does the User have permission to left-click drag on the Canvas?
   * @param {User} user                    The User performing the action.
   * @param {PIXI.FederatedEvent} event    The event object.
   * @returns {boolean}
   */
  #canDragLeftStart(user, event) {
    const layer = this.activeLayer;
    if ( (layer instanceof TokenLayer) && CONFIG.Canvas.rulerClass.canMeasure ) return !this.controls.ruler.active;
    if ( ["select", "target"].includes(game.activeTool) ) return true;
    if ( layer instanceof InteractionLayer ) return layer._canDragLeftStart(user, event);
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle the beginning of a left-mouse drag workflow on the Canvas stage or its active Layer.
   * @see {MouseInteractionManager##handleDragStart}
   * @param {PIXI.FederatedEvent} event
   */
  #onDragLeftStart(event) {
    const layer = this.activeLayer;

    // Begin ruler measurement
    if ( (layer instanceof TokenLayer) && CONFIG.Canvas.rulerClass.canMeasure ) {
      event.interactionData.ruler = true;
      return this.controls.ruler._onDragStart(event);
    }

    // Activate select rectangle
    const isSelect = ["select", "target"].includes(game.activeTool);
    if ( isSelect ) {
      // The event object appears to be reused, so delete any coords from a previous selection.
      delete event.interactionData.coords;
      canvas.controls.select.active = true;
      return;
    }

    // Dispatch the event to the active layer
    if ( layer instanceof InteractionLayer ) return layer._onDragLeftStart(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse movement events occurring on the Canvas.
   * @see {MouseInteractionManager##handleDragMove}
   * @param {PIXI.FederatedEvent} event
   */
  #onDragLeftMove(event) {
    const layer = this.activeLayer;

    // Pan the canvas if the drag event approaches the edge
    this._onDragCanvasPan(event);

    // Continue a Ruler measurement
    if ( event.interactionData.ruler ) return this.controls.ruler._onMouseMove(event);

    // Continue a select event
    const isSelect = ["select", "target"].includes(game.activeTool);
    if ( isSelect && canvas.controls.select.active ) return this.#onDragSelect(event);

    // Dispatch the event to the active layer
    if ( layer instanceof InteractionLayer ) return layer._onDragLeftMove(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle the conclusion of a left-mouse drag workflow when the mouse button is released.
   * @see {MouseInteractionManager##handleDragDrop}
   * @param {PIXI.FederatedEvent} event
   * @internal
   */
  #onDragLeftDrop(event) {

    // Extract event data
    const coords = event.interactionData.coords;
    const tool = game.activeTool;
    const layer = canvas.activeLayer;

    // Conclude a measurement event if we aren't holding the CTRL key
    if ( event.interactionData.ruler ) return canvas.controls.ruler._onMouseUp(event);

    // Conclude a select event
    const isSelect = ["select", "target"].includes(tool);
    const targetKeyDown = game.keyboard.isCoreActionKeyActive("target");
    if ( isSelect && canvas.controls.select.active && (layer instanceof PlaceablesLayer) ) {
      canvas.controls.select.clear();
      canvas.controls.select.active = false;
      const releaseOthers = !event.shiftKey;
      if ( !coords ) return;
      if ( tool === "select" && !targetKeyDown ) return layer.selectObjects(coords, {releaseOthers});
      else if ( tool === "target" || targetKeyDown ) return layer.targetObjects(coords, {releaseOthers});
    }

    // Dispatch the event to the active layer
    if ( layer instanceof InteractionLayer ) return layer._onDragLeftDrop(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle the cancellation of a left-mouse drag workflow
   * @see {MouseInteractionManager##handleDragCancel}
   * @param {PointerEvent} event
   * @internal
   */
  #onDragLeftCancel(event) {
    const layer = canvas.activeLayer;
    const tool = game.activeTool;

    // Don't cancel ruler measurement unless the token was moved by the ruler
    if ( event.interactionData.ruler ) {
      const ruler = canvas.controls.ruler;
      return !ruler.active || (ruler.state === Ruler.STATES.MOVING);
    }

    // Clear selection
    const isSelect = ["select", "target"].includes(tool);
    if ( isSelect ) {
      canvas.controls.select.clear();
      return;
    }

    // Dispatch the event to the active layer
    if ( layer instanceof InteractionLayer ) return layer._onDragLeftCancel(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle right mouse-click events occurring on the Canvas.
   * @see {MouseInteractionManager##handleClickRight}
   * @param {PIXI.FederatedEvent} event
   */
  #onClickRight(event) {
    const ruler = canvas.controls.ruler;
    if ( ruler.state === Ruler.STATES.MEASURING ) return ruler._onClickRight(event);

    // Dispatch to the active layer
    const layer = this.activeLayer;
    if ( layer instanceof InteractionLayer ) return layer._onClickRight(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle double right-click events occurring on the Canvas.
   * @see {MouseInteractionManager##handleClickRight}
   * @param {PIXI.FederatedEvent} event
   */
  #onClickRight2(event) {
    const layer = this.activeLayer;
    if ( layer instanceof InteractionLayer ) return layer._onClickRight2(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle right-mouse start drag events occurring on the Canvas.
   * @see {MouseInteractionManager##handleDragStart}
   * @param {PIXI.FederatedEvent} event
   * @internal
   */
  _onDragRightStart(event) {}

  /* -------------------------------------------- */

  /**
   * Handle right-mouse drag events occurring on the Canvas.
   * @see {MouseInteractionManager##handleDragMove}
   * @param {PIXI.FederatedEvent} event
   * @internal
   */
  _onDragRightMove(event) {
    // Extract event data
    const {origin, destination} = event.interactionData;
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;

    // Pan the canvas
    this.pan({
      x: canvas.stage.pivot.x - (dx * CONFIG.Canvas.dragSpeedModifier),
      y: canvas.stage.pivot.y - (dy * CONFIG.Canvas.dragSpeedModifier)
    });

    // Reset Token tab cycling
    this.tokens._tabIndex = null;
  }

  /* -------------------------------------------- */

  /**
   * Handle the conclusion of a right-mouse drag workflow the Canvas stage.
   * @see {MouseInteractionManager##handleDragDrop}
   * @param {PIXI.FederatedEvent} event
   * @internal
   */
  _onDragRightDrop(event) {}

  /* -------------------------------------------- */

  /**
   * Handle the cancellation of a right-mouse drag workflow the Canvas stage.
   * @see {MouseInteractionManager##handleDragCancel}
   * @param {PIXI.FederatedEvent} event
   * @internal
   */
  _onDragRightCancel(event) {}

  /* -------------------------------------------- */

  /**
   * Determine selection coordinate rectangle during a mouse-drag workflow
   * @param {PIXI.FederatedEvent} event
   */
  #onDragSelect(event) {

    // Extract event data
    const {origin, destination} = event.interactionData;

    // Determine rectangle coordinates
    let coords = {
      x: Math.min(origin.x, destination.x),
      y: Math.min(origin.y, destination.y),
      width: Math.abs(destination.x - origin.x),
      height: Math.abs(destination.y - origin.y)
    };

    // Draw the select rectangle
    canvas.controls.drawSelect(coords);
    event.interactionData.coords = coords;
  }

  /* -------------------------------------------- */

  /**
   * Pan the canvas view when the cursor position gets close to the edge of the frame
   * @param {MouseEvent} event    The originating mouse movement event
   */
  _onDragCanvasPan(event) {

    // Throttle panning by 200ms
    const now = Date.now();
    if ( now - (this._panTime || 0) <= 200 ) return;
    this._panTime = now;

    // Shift by 3 grid spaces at a time
    const {x, y} = event;
    const pad = 50;
    const shift = (this.dimensions.size * 3) / this.stage.scale.x;

    // Shift horizontally
    let dx = 0;
    if ( x < pad ) dx = -shift;
    else if ( x > window.innerWidth - pad ) dx = shift;

    // Shift vertically
    let dy = 0;
    if ( y < pad ) dy = -shift;
    else if ( y > window.innerHeight - pad ) dy = shift;

    // Enact panning
    if ( dx || dy ) return this.animatePan({x: this.stage.pivot.x + dx, y: this.stage.pivot.y + dy, duration: 200});
  }

  /* -------------------------------------------- */
  /*  Other Event Handlers                        */
  /* -------------------------------------------- */

  /**
   * Handle window resizing with the dimensions of the window viewport change
   * @param {Event} event     The Window resize event
   * @private
   */
  _onResize(event=null) {
    if ( !this.#ready ) return false;

    // Resize the renderer to the current screen dimensions
    this.app.renderer.resize(window.innerWidth, window.innerHeight);

    // Record the dimensions that were resized to (may be rounded, etc..)
    const w = this.screenDimensions[0] = this.app.renderer.screen.width;
    const h = this.screenDimensions[1] = this.app.renderer.screen.height;

    // Update the canvas position
    this.stage.position.set(w/2, h/2);
    this.pan(this.stage.pivot);
  }

  /* -------------------------------------------- */

  /**
   * Handle mousewheel events which adjust the scale of the canvas
   * @param {WheelEvent} event    The mousewheel event that zooms the canvas
   * @private
   */
  _onMouseWheel(event) {
    let dz = ( event.delta < 0 ) ? 1.05 : 0.95;
    this.pan({scale: dz * canvas.stage.scale.x});
  }

  /* -------------------------------------------- */

  /**
   * Event handler for the drop portion of a drag-and-drop event.
   * @param {DragEvent} event  The drag event being dropped onto the canvas
   * @private
   */
  _onDrop(event) {
    event.preventDefault();
    const data = TextEditor.getDragEventData(event);
    if ( !data.type ) return;

    // Acquire the cursor position transformed to Canvas coordinates
    const {x, y} = this.canvasCoordinatesFromClient({x: event.clientX, y: event.clientY});
    data.x = x;
    data.y = y;

    /**
     * A hook event that fires when some useful data is dropped onto the
     * Canvas.
     * @function dropCanvasData
     * @memberof hookEvents
     * @param {Canvas} canvas The Canvas
     * @param {object} data   The data that has been dropped onto the Canvas
     */
    const allowed = Hooks.call("dropCanvasData", this, data);
    if ( allowed === false ) return;

    // Handle different data types
    switch ( data.type ) {
      case "Actor":
        return canvas.tokens._onDropActorData(event, data);
      case "JournalEntry": case "JournalEntryPage":
        return canvas.notes._onDropData(event, data);
      case "Macro":
        return game.user.assignHotbarMacro(null, Number(data.slot));
      case "PlaylistSound":
        return canvas.sounds._onDropData(event, data);
      case "Tile":
        return canvas.tiles._onDropData(event, data);
    }
  }

  /* -------------------------------------------- */
  /*  Pre-Rendering Workflow                      */
  /* -------------------------------------------- */

  /**
   * Track objects which have pending render flags.
   * @enum {Set<RenderFlagObject>}
   */
  pendingRenderFlags;

  /**
   * Cached references to bound ticker functions which can be removed later.
   * @type {Record<string, Function>}
   */
  #tickerFunctions = {};

  /* -------------------------------------------- */

  /**
   * Activate ticker functions which should be called as part of the render loop.
   * This occurs as part of setup for a newly viewed Scene.
   */
  #activateTicker() {
    const p = PIXI.UPDATE_PRIORITY;

    // Define custom ticker priorities
    Object.assign(p, {
      OBJECTS: p.HIGH - 2,
      PRIMARY: p.NORMAL + 3,
      PERCEPTION: p.NORMAL + 2
    });

    // Create pending queues
    Object.defineProperty(this, "pendingRenderFlags", {
      value: {
        OBJECTS: new Set(),
        PERCEPTION: new Set()
      },
      configurable: true,
      writable: false
    });

    // Apply PlaceableObject RenderFlags
    this.#tickerFunctions.OBJECTS = this.#applyRenderFlags.bind(this, this.pendingRenderFlags.OBJECTS);
    this.app.ticker.add(this.#tickerFunctions.OBJECTS, undefined, p.OBJECTS);

    // Update the primary group
    this.#tickerFunctions.PRIMARY = this.primary.update.bind(this.primary);
    this.app.ticker.add(this.#tickerFunctions.PRIMARY, undefined, p.PRIMARY);

    // Update Perception
    this.#tickerFunctions.PERCEPTION = this.#applyRenderFlags.bind(this, this.pendingRenderFlags.PERCEPTION);
    this.app.ticker.add(this.#tickerFunctions.PERCEPTION, undefined, p.PERCEPTION);
  }

  /* -------------------------------------------- */

  /**
   * Deactivate ticker functions which were previously registered.
   * This occurs during tear-down of a previously viewed Scene.
   */
  #deactivateTicker() {
    for ( const queue of Object.values(this.pendingRenderFlags) ) queue.clear();
    for ( const [k, fn] of Object.entries(this.#tickerFunctions) ) {
      canvas.app.ticker.remove(fn);
      delete this.#tickerFunctions[k];
    }
  }

  /* -------------------------------------------- */

  /**
   * Apply pending render flags which should be handled at a certain ticker priority.
   * @param {Set<RenderFlagObject>} queue       The queue of objects to handle
   */
  #applyRenderFlags(queue) {
    if ( !queue.size ) return;
    const objects = Array.from(queue);
    queue.clear();
    for ( const object of objects ) object.applyRenderFlags();
  }

  /* -------------------------------------------- */

  /**
   * Test support for some GPU capabilities and update the supported property.
   * @param {PIXI.Renderer} renderer
   */
  #testSupport(renderer) {
    const supported = {};
    const gl = renderer?.gl;

    if ( !(gl instanceof WebGL2RenderingContext) ) {
      supported.webGL2 = false;
      return supported;
    }

    supported.webGL2 = true;
    let renderTexture;

    // Test support for reading pixels in RED/UNSIGNED_BYTE format
    renderTexture = PIXI.RenderTexture.create({
      width: 1,
      height: 1,
      format: PIXI.FORMATS.RED,
      type: PIXI.TYPES.UNSIGNED_BYTE,
      resolution: 1,
      multisample: PIXI.MSAA_QUALITY.NONE
    });
    renderer.renderTexture.bind(renderTexture);
    const format = gl.getParameter(gl.IMPLEMENTATION_COLOR_READ_FORMAT);
    const type = gl.getParameter(gl.IMPLEMENTATION_COLOR_READ_TYPE);
    supported.readPixelsRED = (format === gl.RED) && (type === gl.UNSIGNED_BYTE);
    renderer.renderTexture.bind();
    renderTexture?.destroy(true);

    // Test support for OffscreenCanvas
    try {
      supported.offscreenCanvas =
        (typeof OffscreenCanvas !== "undefined") && (!!new OffscreenCanvas(10, 10).getContext("2d"));
    } catch(e) {
      supported.offscreenCanvas = false;
    }
    return supported;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  addPendingOperation(name, fn, scope, args) {
    const msg = "Canvas#addPendingOperation is deprecated without replacement in v11. The callback that you have "
      + "passed as a pending operation has been executed immediately. We recommend switching your code to use a "
      + "debounce operation or RenderFlags to de-duplicate overlapping requests.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    fn.call(scope, ...args);
  }

  /**
   * @deprecated since v11
   * @ignore
   */
  triggerPendingOperations() {
    const msg = "Canvas#triggerPendingOperations is deprecated without replacement in v11 and performs no action.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
  }

  /**
   * @deprecated since v11
   * @ignore
   */
  get pendingOperations() {
    const msg = "Canvas#pendingOperations is deprecated without replacement in v11.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return [];
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  get colorManager() {
    const msg = "Canvas#colorManager is deprecated and replaced by Canvas#environment";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.environment;
  }
}

/**
 * An Abstract Base Class which defines a Placeable Object which represents a Document placed on the Canvas
 * @extends {PIXI.Container}
 * @abstract
 * @interface
 *
 * @param {abstract.Document} document      The Document instance which is represented by this object
 */
class PlaceableObject extends RenderFlagsMixin(PIXI.Container) {
  constructor(document) {
    super();
    if ( !(document instanceof foundry.abstract.Document) || !document.isEmbedded ) {
      throw new Error("You must provide an embedded Document instance as the input for a PlaceableObject");
    }

    /**
     * Retain a reference to the Scene within which this Placeable Object resides
     * @type {Scene}
     */
    this.scene = document.parent;

    /**
     * A reference to the Scene embedded Document instance which this object represents
     * @type {abstract.Document}
     */
    this.document = document;

    /**
     * A control icon for interacting with the object
     * @type {ControlIcon|null}
     */
    this.controlIcon = null;

    /**
     * A mouse interaction manager instance which handles mouse workflows related to this object.
     * @type {MouseInteractionManager}
     */
    this.mouseInteractionManager = null;

    // Allow objects to be culled when off-screen
    this.cullable = true;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Identify the official Document name for this PlaceableObject class
   * @type {string}
   */
  static embeddedName;

  /**
   * The flags declared here are required for all PlaceableObject subclasses to also support.
   * @override
   */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState"], alias: true},
    refreshState: {}
  };

  /**
   * The object that this object is a preview of if this object is a preview.
   * @type {PlaceableObject|undefined}
   */
  get _original() {
    return this.#original;
  }

  /**
   * The object that this object is a preview of if this object is a preview.
   * @type {PlaceableObject|undefined}
   */
  #original;

  /* -------------------------------------------- */

  /**
   * The bounds that the placeable was added to the quadtree with.
   * @type {PIXI.Rectangle}
   */
  #lastQuadtreeBounds;

  /**
   * An internal reference to a Promise in-progress to draw the Placeable Object.
   * @type {Promise<PlaceableObject>}
   */
  #drawing = Promise.resolve(this);

  /**
   * Has this Placeable Object been drawn and is there no drawing in progress?
   * @type {boolean}
   */
  #drawn = false;

  /* -------------------------------------------- */

  /**
   * A convenient reference for whether the current User has full control over the document.
   * @type {boolean}
   */
  get isOwner() {
    return this.document.isOwner;
  }

  /* -------------------------------------------- */

  /**
   * The mouse interaction state of this placeable.
   * @type {MouseInteractionManager.INTERACTION_STATES|undefined}
   */
  get interactionState() {
    return this._original?.mouseInteractionManager?.state ?? this.mouseInteractionManager?.state;
  }

  /* -------------------------------------------- */

  /**
   * The bounding box for this PlaceableObject.
   * This is required if the layer uses a Quadtree, otherwise it is optional
   * @type {PIXI.Rectangle}
   */
  get bounds() {
    throw new Error("Each subclass of PlaceableObject must define its own bounds rectangle");
  }

  /* -------------------------------------------- */

  /**
   * The central coordinate pair of the placeable object based on it's own width and height
   * @type {PIXI.Point}
   */
  get center() {
    const d = this.document;
    if ( ("width" in d) && ("height" in d) ) {
      return new PIXI.Point(d.x + (d.width / 2), d.y + (d.height / 2));
    }
    return new PIXI.Point(d.x, d.y);
  }

  /* -------------------------------------------- */

  /**
   * The id of the corresponding Document which this PlaceableObject represents.
   * @type {string}
   */
  get id() {
    return this.document.id;
  }

  /* -------------------------------------------- */

  /**
   * A unique identifier which is used to uniquely identify elements on the canvas related to this object.
   * @type {string}
   */
  get objectId() {
    let id = `${this.document.documentName}.${this.document.id}`;
    if ( this.isPreview ) id += ".preview";
    return id;
  }

  /* -------------------------------------------- */

  /**
   * The named identified for the source object associated with this PlaceableObject.
   * This differs from the objectId because the sourceId is the same for preview objects as for the original.
   * @type {string}
   */
  get sourceId() {
    return `${this.document.documentName}.${this._original?.id ?? this.document.id ?? "preview"}`;
  }

  /* -------------------------------------------- */

  /**
   * Is this placeable object a temporary preview?
   * @type {boolean}
   */
  get isPreview() {
    return !!this._original || !this.document.id;
  }

  /* -------------------------------------------- */

  /**
   * Does there exist a temporary preview of this placeable object?
   * @type {boolean}
   */
  get hasPreview() {
    return !!this._preview;
  }

  /* -------------------------------------------- */

  /**
   * Provide a reference to the CanvasLayer which contains this PlaceableObject.
   * @type {PlaceablesLayer}
   */
  get layer() {
    return this.document.layer;
  }

  /* -------------------------------------------- */

  /**
   * A Form Application which is used to configure the properties of this Placeable Object or the Document it
   * represents.
   * @type {FormApplication}
   */
  get sheet() {
    return this.document.sheet;
  }

  /**
   * An indicator for whether the object is currently controlled
   * @type {boolean}
   */
  get controlled() {
    return this.#controlled;
  }

  #controlled = false;

  /* -------------------------------------------- */

  /**
   * An indicator for whether the object is currently a hover target
   * @type {boolean}
   */
  get hover() {
    return this.#hover;
  }

  set hover(state) {
    this.#hover = typeof state === "boolean" ? state : false;
  }

  #hover = false;

  /* -------------------------------------------- */

  /**
   * Is the HUD display active for this Placeable?
   * @returns {boolean}
   */
  get hasActiveHUD() {
    return this.layer.hud?.object === this;
  }

  /* -------------------------------------------- */

  /**
   * Get the snapped position for a given position or the current position.
   * @param {Point} [position]    The position to be used instead of the current position
   * @returns {Point}             The snapped position
   */
  getSnappedPosition(position) {
    return this.layer.getSnappedPoint(position ?? this.document);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  applyRenderFlags() {
    if ( !this.renderFlags.size || this._destroyed ) return;
    const flags = this.renderFlags.clear();

    // Full re-draw
    if ( flags.redraw ) {
      this.draw();
      return;
    }

    // Don't refresh until the object is drawn
    if ( !this.#drawn ) return;

    // Incremental refresh
    this._applyRenderFlags(flags);
    Hooks.callAll(`refresh${this.document.documentName}`, this, flags);
  }

  /* -------------------------------------------- */

  /**
   * Apply render flags before a render occurs.
   * @param {Record<string, boolean>} flags  The render flags which must be applied
   * @protected
   */
  _applyRenderFlags(flags) {}

  /* -------------------------------------------- */

  /**
   * Clear the display of the existing object.
   * @returns {PlaceableObject}    The cleared object
   */
  clear() {
    this.removeChildren().forEach(c => c.destroy({children: true}));
    return this;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  destroy(options) {
    this.mouseInteractionManager?.cancel();
    MouseInteractionManager.emulateMoveEvent();
    if ( this._original ) this._original._preview = undefined;
    this.document._object = null;
    this.document._destroyed = true;
    if ( this.controlIcon ) this.controlIcon.destroy();
    this.renderFlags.clear();
    Hooks.callAll(`destroy${this.document.documentName}`, this);
    this._destroy(options);
    return super.destroy(options);
  }

  /**
   * The inner _destroy method which may optionally be defined by each PlaceableObject subclass.
   * @param {object} [options]    Options passed to the initial destroy call
   * @protected
   */
  _destroy(options) {}

  /* -------------------------------------------- */

  /**
   * Draw the placeable object into its parent container
   * @param {object} [options]            Options which may modify the draw and refresh workflow
   * @returns {Promise<PlaceableObject>}  The drawn object
   */
  async draw(options={}) {
    return this.#drawing = this.#drawing.finally(async () => {
      this.#drawn = false;
      const wasVisible = this.visible;
      const wasRenderable = this.renderable;
      this.visible = false;
      this.renderable = false;
      this.clear();
      this.mouseInteractionManager?.cancel();
      MouseInteractionManager.emulateMoveEvent();
      await this._draw(options);
      Hooks.callAll(`draw${this.document.documentName}`, this);
      this.renderFlags.set({refresh: true}); // Refresh all flags
      if ( this.id ) this.activateListeners();
      this.visible = wasVisible;
      this.renderable = wasRenderable;
      this.#drawn = true;
      MouseInteractionManager.emulateMoveEvent();
    });
  }

  /**
   * The inner _draw method which must be defined by each PlaceableObject subclass.
   * @param {object} options            Options which may modify the draw workflow
   * @abstract
   * @protected
   */
  async _draw(options) {
    throw new Error(`The ${this.constructor.name} subclass of PlaceableObject must define the _draw method`);
  }

  /* -------------------------------------------- */

  /**
   * Execute a partial draw.
   * @param {() => Promise<void>} fn      The draw function
   * @returns {Promise<PlaceableObject>}  The drawn object
   * @internal
   */
  async _partialDraw(fn) {
    return this.#drawing = this.#drawing.finally(async () => {
      if ( !this.#drawn ) return;
      await fn();
    });
  }

  /* -------------------------------------------- */

  /**
   * Refresh all incremental render flags for the PlaceableObject.
   * This method is no longer used by the core software but provided for backwards compatibility.
   * @param {object} [options]      Options which may modify the refresh workflow
   * @returns {PlaceableObject}     The refreshed object
   */
  refresh(options={}) {
    this.renderFlags.set({refresh: true});
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Update the quadtree.
   * @internal
   */
  _updateQuadtree() {
    const layer = this.layer;
    if ( !layer.quadtree || this.isPreview ) return;
    if ( this.destroyed || this.parent !== layer.objects ) {
      this.#lastQuadtreeBounds = undefined;
      layer.quadtree.remove(this);
      return;
    }
    const bounds = this.bounds;
    if ( !this.#lastQuadtreeBounds
      || bounds.x !== this.#lastQuadtreeBounds.x
      || bounds.y !== this.#lastQuadtreeBounds.y
      || bounds.width !== this.#lastQuadtreeBounds.width
      || bounds.height !== this.#lastQuadtreeBounds.height ) {
      this.#lastQuadtreeBounds = bounds;
      layer.quadtree.update({r: bounds, t: this});
    }
  }

  /* -------------------------------------------- */

  /**
   * Is this PlaceableObject within the selection rectangle?
   * @param {PIXI.Rectangle} rectangle    The selection rectangle
   * @protected
   * @internal
   */
  _overlapsSelection(rectangle) {
    const {x, y} = this.center;
    return rectangle.contains(x, y);
  }

  /* -------------------------------------------- */

  /**
   * Get the target opacity that should be used for a Placeable Object depending on its preview state.
   * @returns {number}
   * @protected
   */
  _getTargetAlpha() {
    const isDragging = this._original?.mouseInteractionManager?.isDragging ?? this.mouseInteractionManager?.isDragging;
    return isDragging ? (this.isPreview ? 0.8 : (this.hasPreview ? 0.4 : 1)) : 1;
  }

  /* -------------------------------------------- */

  /**
   * Register pending canvas operations which should occur after a new PlaceableObject of this type is created
   * @param {object} data
   * @param {object} options
   * @param {string} userId
   * @protected
   */
  _onCreate(data, options, userId) {}

  /* -------------------------------------------- */

  /**
   * Define additional steps taken when an existing placeable object of this type is updated with new data
   * @param {object} changed
   * @param {object} options
   * @param {string} userId
   * @protected
   */
  _onUpdate(changed, options, userId) {
    this._updateQuadtree();
    if ( this.parent && (("elevation" in changed) || ("sort" in changed)) ) this.parent.sortDirty = true;
  }

  /* -------------------------------------------- */

  /**
   * Define additional steps taken when an existing placeable object of this type is deleted
   * @param {object} options
   * @param {string} userId
   * @protected
   */
  _onDelete(options, userId) {
    this.release({trigger: false});
    const layer = this.layer;
    if ( layer.hover === this ) layer.hover = null;
    this.destroy({children: true});
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Assume control over a PlaceableObject, flagging it as controlled and enabling downstream behaviors
   * @param {Object} options                  Additional options which modify the control request
   * @param {boolean} options.releaseOthers   Release any other controlled objects first
   * @returns {boolean}                        A flag denoting whether control was successful
   */
  control(options={}) {
    if ( !this.layer.options.controllableObjects ) return false;

    // Release other controlled objects
    if ( options.releaseOthers !== false ) {
      for ( let o of this.layer.controlled ) {
        if ( o !== this ) o.release();
      }
    }

    // Bail out if this object is already controlled, or not controllable
    if ( this.#controlled || !this.id ) return true;
    if ( !this.can(game.user, "control") ) return false;

    // Toggle control status
    this.#controlled = true;
    this.layer.controlledObjects.set(this.id, this);

    // Trigger follow-up events and fire an on-control Hook
    this._onControl(options);
    Hooks.callAll(`control${this.constructor.embeddedName}`, this, this.#controlled);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Additional events which trigger once control of the object is established
   * @param {Object} options    Optional parameters which apply for specific implementations
   * @protected
   */
  _onControl(options) {
    this.renderFlags.set({refreshState: true});
  }

  /* -------------------------------------------- */

  /**
   * Release control over a PlaceableObject, removing it from the controlled set
   * @param {object} options          Options which modify the releasing workflow
   * @returns {boolean}               A Boolean flag confirming the object was released.
   */
  release(options={}) {
    this.layer.controlledObjects.delete(this.id);
    if ( !this.#controlled ) return true;
    this.#controlled = false;

    // Trigger follow-up events
    this._onRelease(options);

    // Fire an on-release Hook
    Hooks.callAll(`control${this.constructor.embeddedName}`, this, this.#controlled);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Additional events which trigger once control of the object is released
   * @param {object} options          Options which modify the releasing workflow
   * @protected
   */
  _onRelease(options) {
    const layer = this.layer;
    this.hover = false;
    if ( this === layer.hover ) layer.hover = null;
    if ( this.hasActiveHUD ) layer.hud.clear();
    this.renderFlags.set({refreshState: true});
  }

  /* -------------------------------------------- */

  /**
   * Clone the placeable object, returning a new object with identical attributes.
   * The returned object is non-interactive, and has no assigned ID.
   * If you plan to use it permanently you should call the create method.
   * @returns {PlaceableObject}  A new object with identical data
   */
  clone() {
    const cloneDoc = this.document.clone({}, {keepId: true});
    const clone = new this.constructor(cloneDoc);
    cloneDoc._object = clone;
    clone.#original = this;
    clone.eventMode = "none";
    clone.#controlled = this.#controlled;
    this._preview = clone;
    return clone;
  }

  /* -------------------------------------------- */

  /**
   * Rotate the PlaceableObject to a certain angle of facing
   * @param {number} angle        The desired angle of rotation
   * @param {number} snap         Snap the angle of rotation to a certain target degree increment
   * @returns {Promise<PlaceableObject>} The rotated object
   */
  async rotate(angle, snap) {
    if ( !this.document.schema.has("rotation") ) return this;
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return this;
    }
    const rotation = this._updateRotation({angle, snap});
    await this.document.update({rotation});
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Determine a new angle of rotation for a PlaceableObject either from an explicit angle or from a delta offset.
   * @param {object} options    An object which defines the rotation update parameters
   * @param {number} [options.angle]    An explicit angle, either this or delta must be provided
   * @param {number} [options.delta=0]  A relative angle delta, either this or the angle must be provided
   * @param {number} [options.snap=0]   A precision (in degrees) to which the resulting angle should snap. Default is 0.
   * @returns {number}          The new rotation angle for the object
   * @internal
   */
  _updateRotation({angle, delta=0, snap=0}={}) {
    let degrees = Number.isNumeric(angle) ? angle : this.document.rotation + delta;
    if ( snap > 0 ) degrees = degrees.toNearest(snap);
    return Math.normalizeDegrees(degrees);
  }

  /* -------------------------------------------- */

  /**
   * Obtain a shifted position for the Placeable Object
   * @param {-1|0|1} dx         The number of grid units to shift along the X-axis
   * @param {-1|0|1} dy         The number of grid units to shift along the Y-axis
   * @returns {Point}           The shifted target coordinates
   * @internal
   */
  _getShiftedPosition(dx, dy) {
    const {x, y} = this.document;
    const snapped = this.getSnappedPosition();
    const D = CONST.MOVEMENT_DIRECTIONS;
    let direction = 0;
    if ( dx < 0 ) {
      if ( x <= snapped.x + 0.5 ) direction |= D.LEFT;
    } else if ( dx > 0 ) {
      if ( x >= snapped.x - 0.5 ) direction |= D.RIGHT;
    }
    if ( dy < 0 ) {
      if ( y <= snapped.y + 0.5 ) direction |= D.UP;
    } else if ( dy > 0 ) {
      if ( y >= snapped.y - 0.5 ) direction |= D.DOWN;
    }
    const grid = this.scene.grid;
    let biasX = 0;
    let biasY = 0;
    if ( grid.isHexagonal ) {
      if ( grid.columns ) biasY = 1;
      else biasX = 1;
    }
    snapped.x += biasX;
    snapped.y += biasY;
    const shifted = grid.getShiftedPoint(snapped, direction);
    shifted.x -= biasX;
    shifted.y -= biasY;
    return shifted;
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /**
   * Activate interactivity for the Placeable Object
   */
  activateListeners() {
    const mgr = this._createInteractionManager();
    this.mouseInteractionManager = mgr.activate();
  }

  /* -------------------------------------------- */

  /**
   * Create a standard MouseInteractionManager for the PlaceableObject
   * @protected
   */
  _createInteractionManager() {

    // Handle permissions to perform various actions
    const permissions = {
      hoverIn: this._canHover,
      clickLeft: this._canControl,
      clickLeft2: this._canView,
      clickRight: this._canHUD,
      clickRight2: this._canConfigure,
      dragStart: this._canDrag,
      dragLeftStart: this._canDragLeftStart
    };

    // Define callback functions for each workflow step
    const callbacks = {
      hoverIn: this._onHoverIn,
      hoverOut: this._onHoverOut,
      clickLeft: this._onClickLeft,
      clickLeft2: this._onClickLeft2,
      clickRight: this._onClickRight,
      clickRight2: this._onClickRight2,
      unclickLeft: this._onUnclickLeft,
      unclickRight: this._onUnclickRight,
      dragLeftStart: this._onDragLeftStart,
      dragLeftMove: this._onDragLeftMove,
      dragLeftDrop: this._onDragLeftDrop,
      dragLeftCancel: this._onDragLeftCancel,
      dragRightStart: this._onDragRightStart,
      dragRightMove: this._onDragRightMove,
      dragRightDrop: this._onDragRightDrop,
      dragRightCancel: this._onDragRightCancel,
      longPress: this._onLongPress
    };

    // Define options
    const options = { target: this.controlIcon ? "controlIcon" : null };

    // Create the interaction manager
    return new MouseInteractionManager(this, canvas.stage, permissions, callbacks, options);
  }

  /* -------------------------------------------- */

  /**
   * Test whether a user can perform a certain interaction regarding a Placeable Object
   * @param {User} user       The User performing the action
   * @param {string} action   The named action being attempted
   * @returns {boolean}       Does the User have rights to perform the action?
   */
  can(user, action) {
    const fn = this[`_can${action.titleCase()}`];
    return fn ? fn.call(this, user) : false;
  }

  /* -------------------------------------------- */

  /**
   * Can the User access the HUD for this Placeable Object?
   * @param {User} user       The User performing the action.
   * @param {object} event    The event object.
   * @returns {boolean}       The returned status.
   * @protected
   */
  _canHUD(user, event) {
    return this.isOwner;
  }

  /* -------------------------------------------- */

  /**
   * Does the User have permission to configure the Placeable Object?
   * @param {User} user       The User performing the action.
   * @param {object} event    The event object.
   * @returns {boolean}       The returned status.
   * @protected
   */
  _canConfigure(user, event) {
    return this.document.canUserModify(user, "update");
  }

  /* -------------------------------------------- */

  /**
   * Does the User have permission to control the Placeable Object?
   * @param {User} user       The User performing the action.
   * @param {object} event    The event object.
   * @returns {boolean}       The returned status.
   * @protected
   */
  _canControl(user, event) {
    if ( !this.layer.active || this.isPreview ) return false;
    return this.document.canUserModify(user, "update");
  }

  /* -------------------------------------------- */

  /**
   * Does the User have permission to view details of the Placeable Object?
   * @param {User} user       The User performing the action.
   * @param {object} event    The event object.
   * @returns {boolean}       The returned status.
   * @protected
   */
  _canView(user, event) {
    return this.document.testUserPermission(user, "LIMITED");
  }

  /* -------------------------------------------- */

  /**
   * Does the User have permission to create the underlying Document?
   * @param {User} user       The User performing the action.
   * @param {object} event    The event object.
   * @returns {boolean}       The returned status.
   * @protected
   */
  _canCreate(user, event) {
    return user.isGM;
  }

  /* -------------------------------------------- */

  /**
   * Does the User have permission to drag this Placeable Object?
   * @param {User} user       The User performing the action.
   * @param {object} event    The event object.
   * @returns {boolean}       The returned status.
   * @protected
   */
  _canDrag(user, event) {
    return this._canControl(user, event);
  }

  /* -------------------------------------------- */

  /**
   * Does the User have permission to left-click drag this Placeable Object?
   * @param {User} user       The User performing the action.
   * @param {object} event    The event object.
   * @returns {boolean}       The returned status.
   * @protected
   */
  _canDragLeftStart(user, event) {
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return false;
    }
    if ( this.document.schema.has("locked") && this.document.locked ) {
      ui.notifications.warn(game.i18n.format("CONTROLS.ObjectIsLocked", {
        type: game.i18n.localize(this.document.constructor.metadata.label)}));
      return false;
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Does the User have permission to hover on this Placeable Object?
   * @param {User} user       The User performing the action.
   * @param {object} event    The event object.
   * @returns {boolean}       The returned status.
   * @protected
   */
  _canHover(user, event) {
    return this._canControl(user, event);
  }

  /* -------------------------------------------- */

  /**
   * Does the User have permission to update the underlying Document?
   * @param {User} user       The User performing the action.
   * @param {object} event    The event object.
   * @returns {boolean}       The returned status.
   * @protected
   */
  _canUpdate(user, event) {
    return this._canControl(user, event);
  }

  /* -------------------------------------------- */

  /**
   * Does the User have permission to delete the underlying Document?
   * @param {User} user       The User performing the action.
   * @param {object} event    The event object.
   * @returns {boolean}       The returned status.
   * @protected
   */
  _canDelete(user, event) {
    return this._canControl(user, event);
  }

  /* -------------------------------------------- */

  /**
   * Actions that should be taken for this Placeable Object when a mouseover event occurs.
   * Hover events on PlaceableObject instances allow event propagation by default.
   * @see MouseInteractionManager##handlePointerOver
   * @param {PIXI.FederatedEvent} event                The triggering canvas interaction event
   * @param {object} options                           Options which customize event handling
   * @param {boolean} [options.hoverOutOthers=false]   Trigger hover-out behavior on sibling objects
   * @protected
   */
  _onHoverIn(event, {hoverOutOthers=false}={}) {
    if ( this.hover ) return;
    if ( event.buttons & 0x03 ) return; // Returning if hovering is happening with pressed left or right button

    // Handle the event
    const layer = this.layer;
    layer.hover = this;
    if ( hoverOutOthers ) {
      for ( const o of layer.placeables ) {
        if ( o !== this ) o._onHoverOut(event);
      }
    }
    this.hover = true;

    // Set render flags
    this.renderFlags.set({refreshState: true});
    Hooks.callAll(`hover${this.constructor.embeddedName}`, this, this.hover);
  }

  /* -------------------------------------------- */

  /**
   * Actions that should be taken for this Placeable Object when a mouseout event occurs
   * @see MouseInteractionManager##handlePointerOut
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onHoverOut(event) {
    if ( !this.hover ) return;

    // Handle the event
    const layer = this.layer;
    layer.hover = null;
    this.hover = false;

    // Set render flags
    this.renderFlags.set({refreshState: true});
    Hooks.callAll(`hover${this.constructor.embeddedName}`, this, this.hover);
  }

  /* -------------------------------------------- */

  /**
   * Should the placeable propagate left click downstream?
   * @param {PIXI.FederatedEvent} event
   * @returns {boolean}
   * @protected
   */
  _propagateLeftClick(event) {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a single left-click event to assume control of the object
   * @see MouseInteractionManager##handleClickLeft
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onClickLeft(event) {
    this.layer.hud?.clear();

    // Add or remove the Placeable Object from the currently controlled set
    if ( !this.#controlled ) this.control({releaseOthers: !event.shiftKey});
    else if ( event.shiftKey ) event.interactionData.release = true; // Release on unclick

    // Propagate left click to the underlying canvas?
    if ( !this._propagateLeftClick(event) ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a single left-unclick event to assume control of the object
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onUnclickLeft(event) {
    // Remove Placeable Object from the currently controlled set
    if ( event.interactionData.release === true ) this.release();

    // Propagate left click to the underlying canvas?
    if ( !this._propagateLeftClick(event) ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a double left-click event to activate
   * @see MouseInteractionManager##handleClickLeft2
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onClickLeft2(event) {
    const sheet = this.sheet;
    if ( sheet ) sheet.render(true);
    if ( !this._propagateLeftClick(event) ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /**
   * Should the placeable propagate right click downstream?
   * @param {PIXI.FederatedEvent} event
   * @returns {boolean}
   * @protected
   */
  _propagateRightClick(event) {
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a single right-click event to configure properties of the object
   * @see MouseInteractionManager##handleClickRight
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onClickRight(event) {
    if ( this.layer.hud ) {
      const releaseOthers = !this.#controlled && !event.shiftKey;
      this.control({releaseOthers});
      if ( this.hasActiveHUD ) this.layer.hud.clear();
      else this.layer.hud.bind(this);
    }

    // Propagate the right-click to the underlying canvas?
    if ( !this._propagateRightClick(event) ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a single right-unclick event
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onUnclickRight(event) {
    // Propagate right-click to the underlying canvas?
    if ( !this._propagateRightClick(event) ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a double right-click event to configure properties of the object
   * @see MouseInteractionManager##handleClickRight2
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onClickRight2(event) {
    const sheet = this.sheet;
    if ( sheet ) sheet.render(true);
    if ( !this._propagateRightClick(event) ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur when a mouse-drag action is first begun.
   * @see MouseInteractionManager##handleDragStart
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onDragLeftStart(event) {
    const objects = this.layer.options.controllableObjects ? this.layer.controlled : [this];
    const clones = [];
    for ( const o of objects ) {
      if ( !o._canDrag(game.user, event) ) continue;
      // FIXME: Find a better solution such that any object for which _canDragLeftStart
      // would return false is included in the drag operation. The locked state might not
      // be the only condition that prevents dragging that is checked in _canDragLeftStart.
      if ( o.document.locked ) continue;

      // Clone the object
      const c = o.clone();
      clones.push(c);

      // Draw the clone
      c._onDragStart();
      c.visible = false;
      this.layer.preview.addChild(c);
      c.draw().then(c => c.visible = true);
    }
    event.interactionData.clones = clones;
  }

  /* -------------------------------------------- */

  /**
   * Begin a drag operation from the perspective of the preview clone.
   * Modify the appearance of both the clone (this) and the original (_original) object.
   * @protected
   */
  _onDragStart() {
    const o = this._original;
    o.document.locked = true;
    o.renderFlags.set({refreshState: true});
  }

  /* -------------------------------------------- */

  /**
   * Conclude a drag operation from the perspective of the preview clone.
   * Modify the appearance of both the clone (this) and the original (_original) object.
   * @protected
   */
  _onDragEnd() {
    const o = this._original;
    if ( o ) {
      o.document.locked = o.document._source.locked;
      o.renderFlags.set({refreshState: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a mouse-move operation.
   * @see MouseInteractionManager##handleDragMove
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onDragLeftMove(event) {
    canvas._onDragCanvasPan(event);
    const {clones, destination, origin} = event.interactionData;

    // Calculate the (snapped) position of the dragged object
    let position = {
      x: this.document.x + (destination.x - origin.x),
      y: this.document.y + (destination.y - origin.y)
    };
    if ( !event.shiftKey ) position = this.getSnappedPosition(position);

    // Move all other objects in the selection relative to the the dragged object.
    // We want to avoid that the dragged object doesn't move when the cursor is moved,
    // because it snaps to the same position, but other objects in the selection do.
    const dx = position.x - this.document.x;
    const dy = position.y - this.document.y;
    for ( const c of clones || [] ) {
      const o = c._original;
      let position = {x: o.document.x + dx, y: o.document.y + dy};
      if ( !event.shiftKey ) position = this.getSnappedPosition(position);
      c.document.x = position.x;
      c.document.y = position.y;
      c.renderFlags.set({refreshPosition: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a mouse-move operation.
   * @see MouseInteractionManager##handleDragDrop
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onDragLeftDrop(event) {

    // Ensure that we landed in bounds
    const {clones, destination} = event.interactionData;
    if ( !clones || !canvas.dimensions.rect.contains(destination.x, destination.y) ) return false;
    event.interactionData.clearPreviewContainer = false;

    // Perform database updates using dropped data
    const updates = this._prepareDragLeftDropUpdates(event);
    // noinspection ES6MissingAwait
    if ( updates ) this.#commitDragLeftDropUpdates(updates);
  }

  /* -------------------------------------------- */

  /**
   * Perform the database updates that should occur as the result of a drag-left-drop operation.
   * @param {PIXI.FederatedEvent} event The triggering canvas interaction event
   * @returns {object[]|null}           An array of database updates to perform for documents in this collection
   */
  _prepareDragLeftDropUpdates(event) {
    const updates = [];
    for ( const clone of event.interactionData.clones ) {
      let dest = {x: clone.document.x, y: clone.document.y};
      if ( !event.shiftKey ) dest = this.getSnappedPosition(dest);
      updates.push({_id: clone._original.id, x: dest.x, y: dest.y, rotation: clone.document.rotation});
    }
    return updates;
  }

  /* -------------------------------------------- */

  /**
   * Perform database updates using the result of a drag-left-drop operation.
   * @param {object[]} updates      The database updates for documents in this collection
   * @returns {Promise<void>}
   */
  async #commitDragLeftDropUpdates(updates) {
    for ( const u of updates ) {
      const d = this.document.collection.get(u._id);
      if ( d ) d.locked = d._source.locked; // Unlock original documents
    }
    await canvas.scene.updateEmbeddedDocuments(this.document.documentName, updates);
    this.layer.clearPreviewContainer();
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a mouse-move operation.
   * @see MouseInteractionManager##handleDragCancel
   * @param {PIXI.FederatedEvent} event  The triggering mouse click event
   * @protected
   */
  _onDragLeftCancel(event) {
    if ( event.interactionData.clearPreviewContainer !== false ) {
      this.layer.clearPreviewContainer();
    }
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a right mouse-drag operation.
   * @see MouseInteractionManager##handleDragStart
   * @param {PIXI.FederatedEvent} event  The triggering mouse click event
   * @protected
   */
  _onDragRightStart(event) {
    return canvas._onDragRightStart(event);
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a right mouse-drag operation.
   * @see MouseInteractionManager##handleDragMove
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @protected
   */
  _onDragRightMove(event) {
    return canvas._onDragRightMove(event);
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a right mouse-drag operation.
   * @see MouseInteractionManager##handleDragDrop
   * @param {PIXI.FederatedEvent} event  The triggering canvas interaction event
   * @returns {Promise<*>}
   * @protected
   */
  _onDragRightDrop(event) {
    return canvas._onDragRightDrop(event);
  }

  /* -------------------------------------------- */

  /**
   * Callback actions which occur on a right mouse-drag operation.
   * @see MouseInteractionManager##handleDragCancel
   * @param {PIXI.FederatedEvent} event  The triggering mouse click event
   * @protected
   */
  _onDragRightCancel(event) {
    return canvas._onDragRightCancel(event);
  }

  /* -------------------------------------------- */

  /**
   * Callback action which occurs on a long press.
   * @see MouseInteractionManager##handleLongPress
   * @param {PIXI.FederatedEvent}   event   The triggering canvas interaction event
   * @param {PIXI.Point}            origin  The local canvas coordinates of the mousepress.
   * @protected
   */
  _onLongPress(event, origin) {
    return canvas.controls._onLongPress(event, origin);
  }
}

/**
 * A Loader class which helps with loading video and image textures.
 */
class TextureLoader {

  /**
   * The duration in milliseconds for which a texture will remain cached
   * @type {number}
   */
  static CACHE_TTL = 1000 * 60 * 15;

  /**
   * Record the timestamps when each asset path is retrieved from cache.
   * @type {Map<PIXI.BaseTexture|PIXI.Spritesheet,{src:string,time:number}>}
   */
  static #cacheTime = new Map();

  /**
   * A mapping of cached texture data
   * @type {WeakMap<PIXI.BaseTexture,Map<string, TextureAlphaData>>}
   */
  static #textureDataMap = new WeakMap();

  /**
   * Create a fixed retry string to use for CORS retries.
   * @type {string}
   */
  static #retryString = Date.now().toString();

  /**
   * To know if the basis transcoder has been initialized
   * @type {boolean}
   */
  static #basisTranscoderInitialized = false;

  /* -------------------------------------------- */

  /**
   * Initialize the basis transcoder for PIXI.Assets
   * @returns {Promise<*>}
   */
  static async initializeBasisTranscoder() {
    if ( this.#basisTranscoderInitialized ) return;
    this.#basisTranscoderInitialized = true;
    return await PIXI.TranscoderWorker.loadTranscoder(
      "scripts/basis_transcoder.js",
      "scripts/basis_transcoder.wasm"
    );
  }

  /* -------------------------------------------- */

  /**
   * Check if a source has a text file extension.
   * @param {string} src          The source.
   * @returns {boolean}           If the source has a text extension or not.
   */
  static hasTextExtension(src) {
    let rgx = new RegExp(`(\\.${Object.keys(CONST.TEXT_FILE_EXTENSIONS).join("|\\.")})(\\?.*)?`, "i");
    return rgx.test(src);
  }

  /* -------------------------------------------- */

  /**
   * @typedef {Object} TextureAlphaData
   * @property {number} width         The width of the (downscaled) texture.
   * @property {number} height        The height of the (downscaled) texture.
   * @property {number} minX          The minimum x-coordinate with alpha > 0.
   * @property {number} minY          The minimum y-coordinate with alpha > 0.
   * @property {number} maxX          The maximum x-coordinate with alpha > 0 plus 1.
   * @property {number} maxY          The maximum y-coordinate with alpha > 0 plus 1.
   * @property {Uint8Array} data      The array containing the texture alpha values (0-255)
   *                                  with the dimensions (maxX-minX)×(maxY-minY).
   */

  /**
   * Use the texture to create a cached mapping of pixel alpha and cache it.
   * Cache the bounding box of non-transparent pixels for the un-rotated shape.
   * @param {PIXI.Texture} texture                The provided texture.
   * @param {number} [resolution=1]               Resolution of the texture data output.
   * @returns {TextureAlphaData|undefined}        The texture data if the texture is valid, else undefined.
   */
  static getTextureAlphaData(texture, resolution=1) {

    // If texture is not present
    if ( !texture?.valid ) return;

    // Get the base tex and the stringified frame + width/height
    const width = Math.ceil(Math.round(texture.width * texture.resolution) * resolution);
    const height = Math.ceil(Math.round(texture.height * texture.resolution) * resolution);
    const baseTex = texture.baseTexture;
    const frame = texture.frame;
    const sframe = `${frame.x},${frame.y},${frame.width},${frame.height},${width},${height}`;

    // Get frameDataMap and textureData if they exist
    let textureData;
    let frameDataMap = this.#textureDataMap.get(baseTex);
    if ( frameDataMap ) textureData = frameDataMap.get(sframe);

    // If texture data exists for the baseTex/frame couple, we return it
    if ( textureData ) return textureData;
    else textureData = {};

    // Create a temporary Sprite using the provided texture
    const sprite = new PIXI.Sprite(texture);
    sprite.width = textureData.width = width;
    sprite.height = textureData.height = height;
    sprite.anchor.set(0, 0);

    // Create or update the alphaMap render texture
    const tex = PIXI.RenderTexture.create({width: width, height: height});
    canvas.app.renderer.render(sprite, {renderTexture: tex});
    sprite.destroy(false);
    const pixels = canvas.app.renderer.extract.pixels(tex);
    tex.destroy(true);

    // Trim pixels with zero alpha
    let minX = width;
    let minY = height;
    let maxX = 0;
    let maxY = 0;
    for ( let i = 3, y = 0; y < height; y++ ) {
      for ( let x = 0; x < width; x++, i += 4 ) {
        const alpha = pixels[i];
        if ( alpha === 0 ) continue;
        if ( x < minX ) minX = x;
        if ( x >= maxX ) maxX = x + 1;
        if ( y < minY ) minY = y;
        if ( y >= maxY ) maxY = y + 1;
      }
    }

    // Special case when the whole texture is alpha 0
    if ( minX > maxX ) minX = minY = maxX = maxY = 0;

    // Set the bounds of the trimmed region
    textureData.minX = minX;
    textureData.minY = minY;
    textureData.maxX = maxX;
    textureData.maxY = maxY;

    // Create new buffer for storing the alpha channel only
    const data = textureData.data = new Uint8Array((maxX - minX) * (maxY - minY));
    for ( let i = 0, y = minY; y < maxY; y++ ) {
      for ( let x = minX; x < maxX; x++, i++ ) {
        data[i] = pixels[(((width * y) + x) * 4) + 3];
      }
    }

    // Saving the texture data
    if ( !frameDataMap ) {
      frameDataMap = new Map();
      this.#textureDataMap.set(baseTex, frameDataMap);
    }
    frameDataMap.set(sframe, textureData);
    return textureData;
  }

  /* -------------------------------------------- */

  /**
   * Load all the textures which are required for a particular Scene
   * @param {Scene} scene                                 The Scene to load
   * @param {object} [options={}]                         Additional options that configure texture loading
   * @param {boolean} [options.expireCache=true]          Destroy other expired textures
   * @param {boolean} [options.additionalSources=[]]      Additional sources to load during canvas initialize
   * @param {number} [options.maxConcurrent]              The maximum number of textures that can be loaded concurrently
   * @returns {Promise<void[]>}
   */
  static loadSceneTextures(scene, {expireCache=true, additionalSources=[], maxConcurrent}={}) {
    let toLoad = [];

    // Scene background and foreground textures
    if ( scene.background.src ) toLoad.push(scene.background.src);
    if ( scene.foreground ) toLoad.push(scene.foreground);
    if ( scene.fog.overlay ) toLoad.push(scene.fog.overlay);

    // Tiles
    toLoad = toLoad.concat(scene.tiles.reduce((arr, t) => {
      if ( t.texture.src ) arr.push(t.texture.src);
      return arr;
    }, []));

    // Tokens
    toLoad.push(CONFIG.Token.ring.spritesheet);
    toLoad = toLoad.concat(scene.tokens.reduce((arr, t) => {
      if ( t.texture.src ) arr.push(t.texture.src);
      if ( t.ring.enabled ) arr.push(t.ring.subject.texture);
      return arr;
    }, []));

    // Control Icons
    toLoad = toLoad.concat(Object.values(CONFIG.controlIcons));

    // Status Effect textures
    toLoad = toLoad.concat(CONFIG.statusEffects.map(e => e.img ?? /** @deprecated since v12 */ e.icon));

    // Configured scene textures
    toLoad.push(...Object.values(canvas.sceneTextures));

    // Additional requested sources
    toLoad.push(...additionalSources);

    // Load files
    const showName = scene.active || scene.visible;
    const loadName = showName ? (scene.navName || scene.name) : "...";
    return this.loader.load(toLoad, {
      message: game.i18n.format("SCENES.Loading", {name: loadName}),
      expireCache,
      maxConcurrent
    });
  }

  /* -------------------------------------------- */

  /**
   * Load an Array of provided source URL paths
   * @param {string[]} sources      The source URLs to load
   * @param {object} [options={}]   Additional options which modify loading
   * @param {string} [options.message]              The status message to display in the load bar
   * @param {boolean} [options.expireCache=false]   Expire other cached textures?
   * @param {number} [options.maxConcurrent]        The maximum number of textures that can be loaded concurrently.
   * @param {boolean} [options.displayProgress]     Display loading progress bar
   * @returns {Promise<void[]>}     A Promise which resolves once all textures are loaded
   */
  async load(sources, {message, expireCache=false, maxConcurrent, displayProgress=true}={}) {
    sources = new Set(sources);
    const progress = {message: message, loaded: 0, failed: 0, total: sources.size, pct: 0};
    console.groupCollapsed(`${vtt} | Loading ${sources.size} Assets`);
    const loadTexture = async src => {
      try {
        await this.loadTexture(src);
        if ( displayProgress ) TextureLoader.#onProgress(src, progress);
      } catch(err) {
        TextureLoader.#onError(src, progress, err);
      }
    };
    const promises = [];
    if ( maxConcurrent ) {
      const semaphore = new foundry.utils.Semaphore(maxConcurrent);
      for ( const src of sources ) promises.push(semaphore.add(loadTexture, src));
    } else {
      for ( const src of sources ) promises.push(loadTexture(src));
    }
    await Promise.allSettled(promises);
    console.groupEnd();
    if ( expireCache ) await this.expireCache();
  }

  /* -------------------------------------------- */

  /**
   * Load a single texture or spritesheet on-demand from a given source URL path
   * @param {string} src                                          The source texture path to load
   * @returns {Promise<PIXI.BaseTexture|PIXI.Spritesheet|null>}   The loaded texture object
   */
  async loadTexture(src) {
    const loadAsset = async (src, bustCache=false) => {
      if ( bustCache ) src = TextureLoader.getCacheBustURL(src);
      if ( !src ) return null;
      try {
        return await PIXI.Assets.load(src);
      } catch ( err ) {
        if ( bustCache ) throw err;
        return await loadAsset(src, true);
      }
    };
    let asset = await loadAsset(src);
    if ( !asset?.baseTexture?.valid ) return null;
    if ( asset instanceof PIXI.Texture ) asset = asset.baseTexture;
    this.setCache(src, asset);
    return asset;
  }

  /* --------------------------------------------- */

  /**
   * Use the Fetch API to retrieve a resource and return a Blob instance for it.
   * @param {string} src
   * @param {object} [options]                   Options to configure the loading behaviour.
   * @param {boolean} [options.bustCache=false]  Append a cache-busting query parameter to the request.
   * @returns {Promise<Blob>}                    A Blob containing the loaded data
   */
  static async fetchResource(src, {bustCache=false}={}) {
    const fail = `Failed to load texture ${src}`;
    const req = bustCache ? TextureLoader.getCacheBustURL(src) : src;
    if ( !req ) throw new Error(`${fail}: Invalid URL`);
    let res;
    try {
      res = await fetch(req, {mode: "cors", credentials: "same-origin"});
    } catch(err) {
      // We may have encountered a common CORS limitation: https://bugs.chromium.org/p/chromium/issues/detail?id=409090
      if ( !bustCache ) return this.fetchResource(src, {bustCache: true});
      throw new Error(`${fail}: CORS failure`);
    }
    if ( !res.ok ) throw new Error(`${fail}: Server responded with ${res.status}`);
    return res.blob();
  }

  /* -------------------------------------------- */

  /**
   * Log texture loading progress in the console and in the Scene loading bar
   * @param {string} src          The source URL being loaded
   * @param {object} progress     Loading progress
   * @private
   */
  static #onProgress(src, progress) {
    progress.loaded++;
    progress.pct = Math.round((progress.loaded + progress.failed) * 100 / progress.total);
    SceneNavigation.displayProgressBar({label: progress.message, pct: progress.pct});
    console.log(`Loaded ${src} (${progress.pct}%)`);
  }

  /* -------------------------------------------- */

  /**
   * Log failed texture loading
   * @param {string} src          The source URL being loaded
   * @param {object} progress     Loading progress
   * @param {Error} error         The error which occurred
   * @private
   */
  static #onError(src, progress, error) {
    progress.failed++;
    progress.pct = Math.round((progress.loaded + progress.failed) * 100 / progress.total);
    SceneNavigation.displayProgressBar({label: progress.message, pct: progress.pct});
    console.warn(`Loading failed for ${src} (${progress.pct}%): ${error.message}`);
  }

  /* -------------------------------------------- */
  /*  Cache Controls                              */
  /* -------------------------------------------- */

  /**
   * Add an image or a sprite sheet url to the assets cache.
   * @param {string} src                                 The source URL.
   * @param {PIXI.BaseTexture|PIXI.Spritesheet} asset    The asset
   */
  setCache(src, asset) {
    TextureLoader.#cacheTime.set(asset, {src, time: Date.now()});
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a texture or a sprite sheet from the assets cache
   * @param {string} src                                     The source URL
   * @returns {PIXI.BaseTexture|PIXI.Spritesheet|null}       The cached texture, a sprite sheet or undefined
   */
  getCache(src) {
    if ( !src ) return null;
    if ( !PIXI.Assets.cache.has(src) ) src = TextureLoader.getCacheBustURL(src) || src;
    let asset = PIXI.Assets.get(src);
    if ( !asset?.baseTexture?.valid ) return null;
    if ( asset instanceof PIXI.Texture ) asset = asset.baseTexture;
    this.setCache(src, asset);
    return asset;
  }

  /* -------------------------------------------- */

  /**
   * Expire and unload assets from the cache which have not been used for more than CACHE_TTL milliseconds.
   */
  async expireCache() {
    const promises = [];
    const t = Date.now();
    for ( const [asset, {src, time}] of TextureLoader.#cacheTime.entries() ) {
      const baseTexture = asset instanceof PIXI.Spritesheet ? asset.baseTexture : asset;
      if ( !baseTexture || baseTexture.destroyed ) {
        TextureLoader.#cacheTime.delete(asset);
        continue;
      }
      if ( (t - time) <= TextureLoader.CACHE_TTL ) continue;
      console.log(`${vtt} | Expiring cached texture: ${src}`);
      promises.push(PIXI.Assets.unload(src));
      TextureLoader.#cacheTime.delete(asset);
    }
    await Promise.allSettled(promises);
  }

  /* -------------------------------------------- */

  /**
   * Return a URL with a cache-busting query parameter appended.
   * @param {string} src        The source URL being attempted
   * @returns {string|boolean}  The new URL, or false on a failure.
   */
  static getCacheBustURL(src) {
    const url = URL.parseSafe(src);
    if ( !url ) return false;
    if ( url.origin === window.location.origin ) return false;
    url.searchParams.append("cors-retry", TextureLoader.#retryString);
    return url.href;
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  async loadImageTexture(src) {
    const warning = "TextureLoader#loadImageTexture is deprecated. Use TextureLoader#loadTexture instead.";
    foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
    return this.loadTexture(src);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  async loadVideoTexture(src) {
    const warning = "TextureLoader#loadVideoTexture is deprecated. Use TextureLoader#loadTexture instead.";
    foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
    return this.loadTexture(src);
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  static get textureBufferDataMap() {
    const warning = "TextureLoader.textureBufferDataMap is deprecated without replacement. Use " +
      "TextureLoader.getTextureAlphaData to create a texture data map and cache it automatically, or create your own" +
      " caching system.";
    foundry.utils.logCompatibilityWarning(warning, {since: 12, until: 14});
    return this.#textureBufferDataMap;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  static #textureBufferDataMap = new Map();
}

/**
 * A global reference to the singleton texture loader
 * @type {TextureLoader}
 */
TextureLoader.loader = new TextureLoader();


/* -------------------------------------------- */


/**
 * Test whether a file source exists by performing a HEAD request against it
 * @param {string} src          The source URL or path to test
 * @returns {Promise<boolean>}   Does the file exist at the provided url?
 */
async function srcExists(src) {
  return foundry.utils.fetchWithTimeout(src, { method: "HEAD" }).then(resp => {
    return resp.status < 400;
  }).catch(() => false);
}


/* -------------------------------------------- */


/**
 * Get a single texture or sprite sheet from the cache.
 * @param {string} src                            The texture path to load.
 * @returns {PIXI.Texture|PIXI.Spritesheet|null}  A texture, a sprite sheet or null if not found in cache.
 */
function getTexture(src) {
  const asset = TextureLoader.loader.getCache(src);
  const baseTexture = asset instanceof PIXI.Spritesheet ? asset.baseTexture : asset;
  if ( !baseTexture?.valid ) return null;
  return (asset instanceof PIXI.Spritesheet ? asset : new PIXI.Texture(asset));
}


/* -------------------------------------------- */


/**
 * Load a single asset and return a Promise which resolves once the asset is ready to use
 * @param {string} src                           The requested asset source
 * @param {object} [options]                     Additional options which modify asset loading
 * @param {string} [options.fallback]            A fallback texture URL to use if the requested source is unavailable
 * @returns {PIXI.Texture|PIXI.Spritesheet|null} The loaded Texture or sprite sheet,
 *                                               or null if loading failed with no fallback
 */
async function loadTexture(src, {fallback}={}) {
  let asset;
  let error;
  try {
    asset = await TextureLoader.loader.loadTexture(src);
    const baseTexture = asset instanceof PIXI.Spritesheet ? asset.baseTexture : asset;
    if ( !baseTexture?.valid ) error = new Error(`Invalid Asset ${src}`);
  }
  catch(err) {
    err.message = `The requested asset ${src} could not be loaded: ${err.message}`;
    error = err;
  }
  if ( error ) {
    console.error(error);
    if ( TextureLoader.hasTextExtension(src) ) return null; // No fallback for spritesheets
    return fallback ? loadTexture(fallback) : null;
  }
  if ( asset instanceof PIXI.Spritesheet ) return asset;
  return new PIXI.Texture(asset);
}

/**
 * A mixin which decorates any container with base canvas common properties.
 * @category - Mixins
 * @param {typeof Container} ContainerClass  The parent Container class being mixed.
 * @returns {typeof CanvasGroupMixin}         A ContainerClass subclass mixed with CanvasGroupMixin features.
 */
const CanvasGroupMixin = ContainerClass => {
  return class CanvasGroup extends ContainerClass {
    constructor(...args) {
      super(...args);
      this.sortableChildren = true;
      this.layers = this._createLayers();
    }

    /**
     * The name of this canvas group.
     * @type {string}
     * @abstract
     */
    static groupName;

    /**
     * If this canvas group should teardown non-layers children.
     * @type {boolean}
     */
    static tearDownChildren = true;

    /**
     * The canonical name of the canvas group is the name of the constructor that is the immediate child of the
     * defined base class.
     * @type {string}
     */
    get name() {
      let cls = Object.getPrototypeOf(this.constructor);
      let name = this.constructor.name;
      while ( cls ) {
        if ( cls !== CanvasGroup ) {
          name = cls.name;
          cls = Object.getPrototypeOf(cls);
        }
        else break;
      }
      return name;
    }

    /**
     * The name used by hooks to construct their hook string.
     * Note: You should override this getter if hookName should not return the class constructor name.
     * @type {string}
     */
    get hookName() {
      return this.name;
    }

    /**
     * A mapping of CanvasLayer classes which belong to this group.
     * @type {Record<string, CanvasLayer>}
     */
    layers;

    /* -------------------------------------------- */

    /**
     * Create CanvasLayer instances which belong to the canvas group.
     * @protected
     */
    _createLayers() {
      const layers = {};
      for ( let [name, config] of Object.entries(CONFIG.Canvas.layers) ) {
        if ( config.group !== this.constructor.groupName ) continue;
        const layer = layers[name] = new config.layerClass();
        Object.defineProperty(this, name, {value: layer, writable: false});
        if ( !(name in canvas) ) Object.defineProperty(canvas, name, {value: layer, writable: false});
      }
      return layers;
    }

    /* -------------------------------------------- */
    /*  Rendering                                   */
    /* -------------------------------------------- */

    /**
     * An internal reference to a Promise in-progress to draw the canvas group.
     * @type {Promise<this>}
     */
    #drawing = Promise.resolve(this);

    /* -------------------------------------------- */

    /**
     * Is the group drawn?
     * @type {boolean}
     */
    #drawn = false;

    /* -------------------------------------------- */

    /**
     * Draw the canvas group and all its components.
     * @param {object} [options={}]
     * @returns {Promise<this>}     A Promise which resolves once the group is fully drawn
     */
    async draw(options={}) {
      return this.#drawing = this.#drawing.finally(async () => {
        console.log(`${vtt} | Drawing the ${this.hookName} canvas group`);
        await this.tearDown();
        await this._draw(options);
        Hooks.callAll(`draw${this.hookName}`, this);
        this.#drawn = true;
        MouseInteractionManager.emulateMoveEvent();
      });
    }

    /**
     * Draw the canvas group and all its component layers.
     * @param {object} options
     * @protected
     */
    async _draw(options) {
      // Draw CanvasLayer instances
      for ( const layer of Object.values(this.layers) ) {
        this.addChild(layer);
        await layer.draw();
      }
    }

    /* -------------------------------------------- */
    /*  Tear-Down                                   */
    /* -------------------------------------------- */

    /**
     * Remove and destroy all layers from the base canvas.
     * @param {object} [options={}]
     * @returns {Promise<this>}
     */
    async tearDown(options={}) {
      if ( !this.#drawn ) return this;
      this.#drawn = false;
      await this._tearDown(options);
      Hooks.callAll(`tearDown${this.hookName}`, this);
      MouseInteractionManager.emulateMoveEvent();
      return this;
    }

    /**
     * Remove and destroy all layers from the base canvas.
     * @param {object} options
     * @protected
     */
    async _tearDown(options) {
      // Remove layers
      for ( const layer of Object.values(this.layers).reverse() ) {
        await layer.tearDown();
        this.removeChild(layer);
      }

      // Check if we need to handle other children
      if ( !this.constructor.tearDownChildren ) return;

      // Yes? Then proceed with children cleaning
      for ( const child of this.removeChildren() ) {
        if ( child instanceof CachedContainer ) child.clear();
        else child.destroy({children: true});
      }
    }
  };
};

/* -------------------------------------------- */
/*  Deprecations and Compatibility              */
/* -------------------------------------------- */

/**
 * @deprecated since v12
 * @ignore
 */
Object.defineProperty(globalThis, "BaseCanvasMixin", {
  get() {
    const msg = "BaseCanvasMixin is deprecated in favor of CanvasGroupMixin";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return CanvasGroupMixin;
  }
});

/**
 * A special type of PIXI.Container which draws its contents to a cached RenderTexture.
 * This is accomplished by overriding the Container#render method to draw to our own special RenderTexture.
 */
class CachedContainer extends PIXI.Container {
  /**
   * Construct a CachedContainer.
   * @param {PIXI.Sprite|SpriteMesh} [sprite]  A specific sprite to bind to this CachedContainer and its renderTexture.
   */
  constructor(sprite) {
    super();
    const renderer = canvas.app?.renderer;

    /**
     * The RenderTexture that is the render destination for the contents of this Container
     * @type {PIXI.RenderTexture}
     */
    this.#renderTexture = this.createRenderTexture();

    // Bind a sprite to the container
    if ( sprite ) this.sprite = sprite;

    // Listen for resize events
    this.#onResize = this.#resize.bind(this, renderer);
    renderer.on("resize", this.#onResize);
  }

  /**
   * The texture configuration to use for this cached container
   * @type {{multisample: PIXI.MSAA_QUALITY, scaleMode: PIXI.SCALE_MODES, format: PIXI.FORMATS}}
   * @abstract
   */
  static textureConfiguration = {};

  /**
   * A bound resize function which fires on the renderer resize event.
   * @type {function(PIXI.Renderer)}
   * @private
   */
  #onResize;

  /**
   * A map of render textures, linked to their render function and an optional RGBA clear color.
   * @type {Map<PIXI.RenderTexture,{renderFunction: Function, clearColor: number[]}>}
   * @protected
   */
  _renderPaths = new Map();

  /**
   * An object which stores a reference to the normal renderer target and source frame.
   * We track this so we can restore them after rendering our cached texture.
   * @type {{sourceFrame: PIXI.Rectangle, renderTexture: PIXI.RenderTexture}}
   * @private
   */
  #backup = {
    renderTexture: undefined,
    sourceFrame: canvas.app.renderer.screen.clone()
  };

  /**
   * An RGBA array used to define the clear color of the RenderTexture
   * @type {number[]}
   */
  clearColor = [0, 0, 0, 1];

  /**
   * Should our Container also be displayed on screen, in addition to being drawn to the cached RenderTexture?
   * @type {boolean}
   */
  displayed = false;

  /**
   * If true, the Container is rendered every frame.
   * If false, the Container is rendered only if {@link CachedContainer#renderDirty} is true.
   * @type {boolean}
   */
  autoRender = true;

  /**
   * Does the Container need to be rendered?
   * Set to false after the Container is rendered.
   * @type {boolean}
   */
  renderDirty = true;

  /* ---------------------------------------- */

  /**
   * The primary render texture bound to this cached container.
   * @type {PIXI.RenderTexture}
   */
  get renderTexture() {
    return this.#renderTexture;
  }

  /** @private */
  #renderTexture;

  /* ---------------------------------------- */

  /**
   * Set the alpha mode of the cached container render texture.
   * @param {PIXI.ALPHA_MODES} mode
   */
  set alphaMode(mode) {
    this.#renderTexture.baseTexture.alphaMode = mode;
    this.#renderTexture.baseTexture.update();
  }

  /* ---------------------------------------- */

  /**
   * A PIXI.Sprite or SpriteMesh which is bound to this CachedContainer.
   * The RenderTexture from this Container is associated with the Sprite which is automatically rendered.
   * @type {PIXI.Sprite|SpriteMesh}
   */
  get sprite() {
    return this.#sprite;
  }

  set sprite(sprite) {
    if ( sprite instanceof PIXI.Sprite || sprite instanceof SpriteMesh ) {
      sprite.texture = this.renderTexture;
      this.#sprite = sprite;
    }
    else if ( sprite ) {
      throw new Error("You may only bind a PIXI.Sprite or a SpriteMesh as the render target for a CachedContainer.");
    }
  }

  /** @private */
  #sprite;

  /* ---------------------------------------- */

  /**
   * Create a render texture, provide a render method and an optional clear color.
   * @param {object} [options={}]                 Optional parameters.
   * @param {Function} [options.renderFunction]   Render function that will be called to render into the RT.
   * @param {number[]} [options.clearColor]       An optional clear color to clear the RT before rendering into it.
   * @returns {PIXI.RenderTexture}              A reference to the created render texture.
   */
  createRenderTexture({renderFunction, clearColor}={}) {
    const renderOptions = {};
    const renderer = canvas.app.renderer;
    const conf = this.constructor.textureConfiguration;
    const pm = canvas.performance.mode;

    // Disabling linear filtering by default for low/medium performance mode
    const defaultScaleMode = (pm > CONST.CANVAS_PERFORMANCE_MODES.MED)
      ? PIXI.SCALE_MODES.LINEAR
      : PIXI.SCALE_MODES.NEAREST;

    // Creating the render texture
    const renderTexture = PIXI.RenderTexture.create({
      width: renderer.screen.width,
      height: renderer.screen.height,
      resolution: renderer.resolution,
      multisample: conf.multisample ?? renderer.multisample,
      scaleMode: conf.scaleMode ?? defaultScaleMode,
      format: conf.format ?? PIXI.FORMATS.RGBA
    });
    renderOptions.renderFunction = renderFunction;            // Binding the render function
    renderOptions.clearColor = clearColor;                    // Saving the optional clear color
    this._renderPaths.set(renderTexture, renderOptions);      // Push into the render paths
    this.renderDirty = true;

    // Return a reference to the render texture
    return renderTexture;
  }

  /* ---------------------------------------- */

  /**
   * Remove a previously created render texture.
   * @param {PIXI.RenderTexture} renderTexture   The render texture to remove.
   * @param {boolean} [destroy=true]             Should the render texture be destroyed?
   */
  removeRenderTexture(renderTexture, destroy=true) {
    this._renderPaths.delete(renderTexture);
    if ( destroy ) renderTexture?.destroy(true);
    this.renderDirty = true;
  }

  /* ---------------------------------------- */

  /**
   * Clear the cached container, removing its current contents.
   * @param {boolean} [destroy=true]    Tell children that we should destroy texture as well.
   * @returns {CachedContainer}         A reference to the cleared container for chaining.
   */
  clear(destroy=true) {
    Canvas.clearContainer(this, destroy);
    return this;
  }

  /* ---------------------------------------- */

  /** @inheritdoc */
  destroy(options) {
    if ( this.#onResize ) canvas.app.renderer.off("resize", this.#onResize);
    for ( const [rt] of this._renderPaths ) rt?.destroy(true);
    this._renderPaths.clear();
    super.destroy(options);
  }

  /* ---------------------------------------- */

  /** @inheritdoc */
  render(renderer) {
    if ( !this.renderable ) return;                           // Skip updating the cached texture
    if ( this.autoRender || this.renderDirty ) {
      this.renderDirty = false;
      this.#bindPrimaryBuffer(renderer);                      // Bind the primary buffer (RT)
      super.render(renderer);                                 // Draw into the primary buffer
      this.#renderSecondary(renderer);                        // Draw into the secondary buffer(s)
      this.#bindOriginalBuffer(renderer);                     // Restore the original buffer
    }
    this.#sprite?.render(renderer);                           // Render the bound sprite
    if ( this.displayed ) super.render(renderer);             // Optionally draw to the screen
  }

  /* ---------------------------------------- */

  /**
   * Custom rendering for secondary render textures
   * @param {PIXI.Renderer} renderer    The active canvas renderer.
   * @protected
   */
  #renderSecondary(renderer) {
    if ( this._renderPaths.size <= 1 ) return;
    // Bind the render texture and call the custom render method for each render path
    for ( const [rt, ro] of this._renderPaths ) {
      if ( !ro.renderFunction ) continue;
      this.#bind(renderer, rt, ro.clearColor);
      ro.renderFunction.call(this, renderer);
    }
  }

  /* ---------------------------------------- */

  /**
   * Bind the primary render texture to the renderer, replacing and saving the original buffer and source frame.
   * @param {PIXI.Renderer} renderer      The active canvas renderer.
   * @private
   */
  #bindPrimaryBuffer(renderer) {

    // Get the RenderTexture to bind
    const tex = this.renderTexture;
    const rt = renderer.renderTexture;

    // Backup the current render target
    this.#backup.renderTexture = rt.current;
    this.#backup.sourceFrame.copyFrom(rt.sourceFrame);

    // Bind the render texture
    this.#bind(renderer, tex);
  }

  /* ---------------------------------------- */

  /**
   * Bind a render texture to this renderer.
   * Must be called after bindPrimaryBuffer and before bindInitialBuffer.
   * @param {PIXI.Renderer} renderer     The active canvas renderer.
   * @param {PIXI.RenderTexture} tex     The texture to bind.
   * @param {number[]} [clearColor]      A custom clear color.
   * @protected
   */
  #bind(renderer, tex, clearColor) {
    const rt = renderer.renderTexture;

    // Bind our texture to the renderer
    renderer.batch.flush();
    rt.bind(tex, undefined, undefined);
    rt.clear(clearColor ?? this.clearColor);

    // Enable Filters which are applied to this Container to apply to our cached RenderTexture
    const fs = renderer.filter.defaultFilterStack;
    if ( fs.length > 1 ) {
      fs[fs.length - 1].renderTexture = tex;
    }
  }

  /* ---------------------------------------- */

  /**
   * Remove the render texture from the Renderer, re-binding the original buffer.
   * @param {PIXI.Renderer} renderer      The active canvas renderer.
   * @private
   */
  #bindOriginalBuffer(renderer) {
    renderer.batch.flush();

    // Restore Filters to apply to the original RenderTexture
    const fs = renderer.filter.defaultFilterStack;
    if ( fs.length > 1 ) {
      fs[fs.length - 1].renderTexture = this.#backup.renderTexture;
    }

    // Re-bind the original RenderTexture to the renderer
    renderer.renderTexture.bind(this.#backup.renderTexture, this.#backup.sourceFrame, undefined);
    this.#backup.renderTexture = undefined;
  }

  /* ---------------------------------------- */

  /**
   * Resize bound render texture(s) when the dimensions or resolution of the Renderer have changed.
   * @param {PIXI.Renderer} renderer      The active canvas renderer.
   * @private
   */
  #resize(renderer) {
    for ( const [rt] of this._renderPaths ) CachedContainer.resizeRenderTexture(renderer, rt);
    if ( this.#sprite ) this.#sprite._boundsID++; // Inform PIXI that bounds need to be recomputed for this sprite mesh
    this.renderDirty = true;
  }

  /* ---------------------------------------- */

  /**
   * Resize a render texture passed as a parameter with the renderer.
   * @param {PIXI.Renderer} renderer    The active canvas renderer.
   * @param {PIXI.RenderTexture} rt     The render texture to resize.
   */
  static resizeRenderTexture(renderer, rt) {
    const screen = renderer?.screen;
    if ( !rt || !screen ) return;
    if ( rt.baseTexture.resolution !== renderer.resolution ) rt.baseTexture.resolution = renderer.resolution;
    if ( (rt.width !== screen.width) || (rt.height !== screen.height) ) rt.resize(screen.width, screen.height);
  }
}

/**
 * Augment any PIXI.DisplayObject to assume bounds that are always aligned with the full visible screen.
 * The bounds of this container do not depend on its children but always fill the entire canvas.
 * @param {typeof PIXI.DisplayObject} Base    Any PIXI DisplayObject subclass
 * @returns {typeof FullCanvasObject}         The decorated subclass with full canvas bounds
 */
function FullCanvasObjectMixin(Base) {
  return class FullCanvasObject extends Base {
    /** @override */
    calculateBounds() {
      const bounds = this._bounds;
      const { x, y, width, height } = canvas.dimensions.rect;
      bounds.clear();
      bounds.addFrame(this.transform, x, y, x + width, y + height);
      bounds.updateID = this._boundsID;
    }
  };
}

/**
 * @deprecated since v11
 * @ignore
 */
class FullCanvasContainer extends FullCanvasObjectMixin(PIXI.Container) {
  constructor(...args) {
    super(...args);
    const msg = "You are using the FullCanvasContainer class which has been deprecated in favor of a more flexible "
      + "FullCanvasObjectMixin which can augment any PIXI.DisplayObject subclass.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
  }
}

/**
 * Extension of a PIXI.Mesh, with the capabilities to provide a snapshot of the framebuffer.
 * @extends PIXI.Mesh
 */
class PointSourceMesh extends PIXI.Mesh {
  /**
   * To store the previous blend mode of the last renderer PointSourceMesh.
   * @type {PIXI.BLEND_MODES}
   * @protected
   */
  static _priorBlendMode;

  /**
   * The current texture used by the mesh.
   * @type {PIXI.Texture}
   * @protected
   */
  static _currentTexture;

  /**
   * The transform world ID of the bounds.
   * @type {number}
   */
  _worldID = -1;

  /**
   * The geometry update ID of the bounds.
   * @type {number}
   */
  _updateID = -1;

  /* -------------------------------------------- */
  /*  PointSourceMesh Properties                  */
  /* -------------------------------------------- */

  /** @override */
  get geometry() {
    return super.geometry;
  }

  /** @override */
  set geometry(value) {
    if ( this._geometry !== value ) this._updateID = -1;
    super.geometry = value;
  }

  /* -------------------------------------------- */
  /*  PointSourceMesh Methods                     */
  /* -------------------------------------------- */

  /** @override */
  addChild() {
    throw new Error("You can't add children to a PointSourceMesh.");
  }

  /* ---------------------------------------- */

  /** @override */
  addChildAt() {
    throw new Error("You can't add children to a PointSourceMesh.");
  }

  /* ---------------------------------------- */

  /** @override */
  _render(renderer) {
    if ( this.uniforms.framebufferTexture !== undefined ) {
      if ( canvas.blur.enabled ) {
        // We need to use the snapshot only if blend mode is changing
        const requireUpdate = (this.state.blendMode !== PointSourceMesh._priorBlendMode)
          && (PointSourceMesh._priorBlendMode !== undefined);
        if ( requireUpdate ) PointSourceMesh._currentTexture = canvas.snapshot.getFramebufferTexture(renderer);
        PointSourceMesh._priorBlendMode = this.state.blendMode;
      }
      this.uniforms.framebufferTexture = PointSourceMesh._currentTexture;
    }
    super._render(renderer);
  }

  /* ---------------------------------------- */

  /** @override */
  calculateBounds() {
    const {transform, geometry} = this;

    // Checking bounds id to update only when it is necessary
    if ( this._worldID !== transform._worldID
      || this._updateID !== geometry.buffers[0]._updateID ) {

      this._worldID = transform._worldID;
      this._updateID = geometry.buffers[0]._updateID;

      const {x, y, width, height} = this.geometry.bounds;
      this._bounds.clear();
      this._bounds.addFrame(transform, x, y, x + width, y + height);
    }

    this._bounds.updateID = this._boundsID;
  }

  /* ---------------------------------------- */

  /** @override */
  _calculateBounds() {
    this.calculateBounds();
  }

  /* ---------------------------------------- */

  /**
   * The local bounds need to be drawn from the underlying geometry.
   * @override
   */
  getLocalBounds(rect) {
    rect ??= this._localBoundsRect ??= new PIXI.Rectangle();
    return this.geometry.bounds.copyTo(rect);
  }
}

/**
 * A basic rectangular mesh with a shader only. Does not natively handle textures (but a bound shader can).
 * Bounds calculations are simplified and the geometry does not need to handle texture coords.
 * @param {AbstractBaseShader} shaderClass     The shader class to use.
 */
class QuadMesh extends PIXI.Container {
  constructor(shaderClass) {
    super();
    // Assign shader, state and properties
    if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
      throw new Error("QuadMesh shader class must inherit from AbstractBaseShader.");
    }
    this.#shader = shaderClass.create();
  }

  /**
   * Geometry bound to this QuadMesh.
   * @type {PIXI.Geometry}
   */
  #geometry = new PIXI.Geometry()
    .addAttribute("aVertexPosition", [0, 0, 1, 0, 1, 1, 0, 1], 2)
    .addIndex([0, 1, 2, 0, 2, 3]);

  /* ---------------------------------------- */

  /**
   * The shader bound to this mesh.
   * @type {AbstractBaseShader}
   */
  get shader() {
    return this.#shader;
  }

  /**
   * @type {AbstractBaseShader}
   */
  #shader;

  /* ---------------------------------------- */

  /**
   * Assigned blend mode to this mesh.
   * @type {PIXI.BLEND_MODES}
   */
  get blendMode() {
    return this.#state.blendMode;
  }

  set blendMode(value) {
    this.#state.blendMode = value;
  }

  /**
   * State bound to this QuadMesh.
   * @type {PIXI.State}
   */
  #state = PIXI.State.for2d();

  /* ---------------------------------------- */

  /**
   * Initialize shader based on the shader class type.
   * @param {class} shaderClass         Shader class used. Must inherit from AbstractBaseShader.
   */
  setShaderClass(shaderClass) {
    // Escape conditions
    if ( !AbstractBaseShader.isPrototypeOf(shaderClass) ) {
      throw new Error("QuadMesh shader class must inherit from AbstractBaseShader.");
    }
    if ( this.#shader.constructor === shaderClass ) return;

    // Create shader program
    this.#shader = shaderClass.create();
  }

  /* ---------------------------------------- */

  /** @override */
  _render(renderer) {
    this.#shader._preRender(this, renderer);
    this.#shader.uniforms.translationMatrix = this.transform.worldTransform.toArray(true);

    // Flush batch renderer
    renderer.batch.flush();

    // Set state
    renderer.state.set(this.#state);

    // Bind shader and geometry
    renderer.shader.bind(this.#shader);
    renderer.geometry.bind(this.#geometry, this.#shader);

    // Draw the geometry
    renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES);
  }

  /* ---------------------------------------- */

  /** @override */
  _calculateBounds() {
    this._bounds.addFrame(this.transform, 0, 0, 1, 1);
  }

  /* ---------------------------------------- */

  /**
   * Tests if a point is inside this QuadMesh.
   * @param {PIXI.IPointData} point
   * @returns {boolean}
   */
  containsPoint(point) {
    return this.getBounds().contains(point.x, point.y);
  }

  /* ---------------------------------------- */

  /** @override */
  destroy(options) {
    super.destroy(options);
    this.#geometry.dispose();
    this.#geometry = null;
    this.#shader = null;
    this.#state = null;
  }
}

/**
 * @typedef {object} QuadtreeObject
 * @property {Rectangle} r
 * @property {*} t
 * @property {Set<Quadtree>} [n]
 */

/**
 * A Quadtree implementation that supports collision detection for rectangles.
 *
 * @param {Rectangle} bounds                The outer bounds of the region
 * @param {object} [options]                Additional options which configure the Quadtree
 * @param {number} [options.maxObjects=20]  The maximum number of objects per node
 * @param {number} [options.maxDepth=4]     The maximum number of levels within the root Quadtree
 * @param {number} [options._depth=0]       The depth level of the sub-tree. For internal use
 * @param {number} [options._root]          The root of the quadtree. For internal use
 */
class Quadtree {
  constructor(bounds, {maxObjects=20, maxDepth=4, _depth=0, _root}={}) {

    /**
     * The bounding rectangle of the region
     * @type {PIXI.Rectangle}
     */
    this.bounds = new PIXI.Rectangle(bounds.x, bounds.y, bounds.width, bounds.height);

    /**
     * The maximum number of objects allowed within this node before it must split
     * @type {number}
     */
    this.maxObjects = maxObjects;

    /**
     * The maximum number of levels that the base quadtree is allowed
     * @type {number}
     */
    this.maxDepth = maxDepth;

    /**
     * The depth of this node within the root Quadtree
     * @type {number}
     */
    this.depth = _depth;

    /**
     * The objects contained at this level of the tree
     * @type {QuadtreeObject[]}
     */
    this.objects = [];

    /**
     * Children of this node
     * @type {Quadtree[]}
     */
    this.nodes = [];

    /**
     * The root Quadtree
     * @type {Quadtree}
     */
    this.root = _root || this;
  }

  /**
   * A constant that enumerates the index order of the quadtree nodes from top-left to bottom-right.
   * @enum {number}
   */
  static INDICES = {tl: 0, tr: 1, bl: 2, br: 3};

  /* -------------------------------------------- */

  /**
   * Return an array of all the objects in the Quadtree (recursive)
   * @returns {QuadtreeObject[]}
   */
  get all() {
    if ( this.nodes.length ) {
      return this.nodes.reduce((arr, n) => arr.concat(n.all), []);
    }
    return this.objects;
  }

  /* -------------------------------------------- */
  /*  Tree Management                             */
  /* -------------------------------------------- */

  /**
   * Split this node into 4 sub-nodes.
   * @returns {Quadtree}     The split Quadtree
   */
  split() {
    const b = this.bounds;
    const w = b.width / 2;
    const h = b.height / 2;
    const options = {
      maxObjects: this.maxObjects,
      maxDepth: this.maxDepth,
      _depth: this.depth + 1,
      _root: this.root
    };

    // Create child quadrants
    this.nodes[Quadtree.INDICES.tl] = new Quadtree(new PIXI.Rectangle(b.x, b.y, w, h), options);
    this.nodes[Quadtree.INDICES.tr] = new Quadtree(new PIXI.Rectangle(b.x+w, b.y, w, h), options);
    this.nodes[Quadtree.INDICES.bl] = new Quadtree(new PIXI.Rectangle(b.x, b.y+h, w, h), options);
    this.nodes[Quadtree.INDICES.br] = new Quadtree(new PIXI.Rectangle(b.x+w, b.y+h, w, h), options);

    // Assign current objects to child nodes
    for ( let o of this.objects ) {
      o.n.delete(this);
      this.insert(o);
    }
    this.objects = [];
    return this;
  }

  /* -------------------------------------------- */
  /*  Object Management                           */
  /* -------------------------------------------- */

  /**
   * Clear the quadtree of all existing contents
   * @returns {Quadtree}     The cleared Quadtree
   */
  clear() {
    this.objects = [];
    for ( let n of this.nodes ) {
      n.clear();
    }
    this.nodes = [];
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Add a rectangle object to the tree
   * @param {QuadtreeObject} obj  The object being inserted
   * @returns {Quadtree[]}        The Quadtree nodes the object was added to.
   */
  insert(obj) {
    obj.n = obj.n || new Set();

    // If we will exceeded the maximum objects we need to split
    if ( (this.objects.length === this.maxObjects - 1) && (this.depth < this.maxDepth) ) {
      if ( !this.nodes.length ) this.split();
    }

    // If this node has children, recursively insert
    if ( this.nodes.length ) {
      let nodes = this.getChildNodes(obj.r);
      return nodes.reduce((arr, n) => arr.concat(n.insert(obj)), []);
    }

    // Otherwise store the object here
    obj.n.add(this);
    this.objects.push(obj);
    return [this];
  }

  /* -------------------------------------------- */

  /**
   * Remove an object from the quadtree
   * @param {*} target     The quadtree target being removed
   * @returns {Quadtree}   The Quadtree for method chaining
   */
  remove(target) {
    this.objects.findSplice(o => o.t === target);
    for ( let n of this.nodes ) {
      n.remove(target);
    }
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Remove an existing object from the quadtree and re-insert it with a new position
   * @param {QuadtreeObject} obj  The object being inserted
   * @returns {Quadtree[]}        The Quadtree nodes the object was added to
   */
  update(obj) {
    this.remove(obj.t);
    return this.insert(obj);
  }

  /* -------------------------------------------- */
  /*  Target Identification                       */
  /* -------------------------------------------- */

  /**
   * Get all the objects which could collide with the provided rectangle
   * @param {Rectangle} rect    The normalized target rectangle
   * @param {object} [options]                    Options affecting the collision test.
   * @param {Function} [options.collisionTest]    Function to further refine objects to return
   *   after a potential collision is found. Parameters are the object and rect, and the
   *   function should return true if the object should be added to the result set.
   * @param {Set} [options._s]                    The existing result set, for internal use.
   * @returns {Set}           The objects in the Quadtree which represent potential collisions
   */
  getObjects(rect, { collisionTest, _s } = {}) {
    const objects = _s || new Set();

    // Recursively retrieve objects from child nodes
    if ( this.nodes.length ) {
      const nodes = this.getChildNodes(rect);
      for ( let n of nodes ) {
        n.getObjects(rect, {collisionTest, _s: objects});
      }
    }

    // Otherwise, retrieve from this node
    else {
      for ( let o of this.objects) {
        if ( rect.overlaps(o.r) && (!collisionTest || collisionTest(o, rect)) ) objects.add(o.t);
      }
    }

    // Return the result set
    return objects;
  }

  /* -------------------------------------------- */

  /**
   * Obtain the leaf nodes to which a target rectangle belongs.
   * This traverses the quadtree recursively obtaining the final nodes which have no children.
   * @param {Rectangle} rect  The target rectangle.
   * @returns {Quadtree[]}    The Quadtree nodes to which the target rectangle belongs
   */
  getLeafNodes(rect) {
    if ( !this.nodes.length ) return [this];
    const nodes = this.getChildNodes(rect);
    return nodes.reduce((arr, n) => arr.concat(n.getLeafNodes(rect)), []);
  }

  /* -------------------------------------------- */

  /**
   * Obtain the child nodes within the current node which a rectangle belongs to.
   * Note that this function is not recursive, it only returns nodes at the current or child level.
   * @param {Rectangle} rect  The target rectangle.
   * @returns {Quadtree[]}    The Quadtree nodes to which the target rectangle belongs
   */
  getChildNodes(rect) {

    // If this node has no children, use it
    if ( !this.nodes.length ) return [this];

    // Prepare data
    const nodes = [];
    const hx = this.bounds.x + (this.bounds.width / 2);
    const hy = this.bounds.y + (this.bounds.height / 2);

    // Determine orientation relative to the node
    const startTop = rect.y <= hy;
    const startLeft = rect.x <= hx;
    const endBottom = (rect.y + rect.height) > hy;
    const endRight = (rect.x + rect.width) > hx;

    // Top-left
    if ( startLeft && startTop ) nodes.push(this.nodes[Quadtree.INDICES.tl]);

    // Top-right
    if ( endRight && startTop ) nodes.push(this.nodes[Quadtree.INDICES.tr]);

    // Bottom-left
    if ( startLeft && endBottom ) nodes.push(this.nodes[Quadtree.INDICES.bl]);

    // Bottom-right
    if ( endRight && endBottom ) nodes.push(this.nodes[Quadtree.INDICES.br]);
    return nodes;
  }

  /* -------------------------------------------- */

  /**
   * Identify all nodes which are adjacent to this one within the parent Quadtree.
   * @returns {Quadtree[]}
   */
  getAdjacentNodes() {
    const bounds = this.bounds.clone().pad(1);
    return this.root.getLeafNodes(bounds);
  }

  /* -------------------------------------------- */

  /**
   * Visualize the nodes and objects in the quadtree
   * @param {boolean} [objects]    Visualize the rectangular bounds of objects in the Quadtree. Default is false.
   * @private
   */
  visualize({objects=false}={}) {
    const debug = canvas.controls.debug;
    if ( this.depth === 0 ) debug.clear().endFill();
    debug.lineStyle(2, 0x00FF00, 0.5).drawRect(this.bounds.x, this.bounds.y, this.bounds.width, this.bounds.height);
    if ( objects ) {
      for ( let o of this.objects ) {
        debug.lineStyle(2, 0xFF0000, 0.5).drawRect(o.r.x, o.r.y, Math.max(o.r.width, 1), Math.max(o.r.height, 1));
      }
    }
    for ( let n of this.nodes ) {
      n.visualize({objects});
    }
  }
}

/* -------------------------------------------- */

/**
 * A subclass of Quadtree specifically intended for classifying the location of objects on the game canvas.
 */
class CanvasQuadtree extends Quadtree {
  constructor(options={}) {
    super({}, options);
    Object.defineProperty(this, "bounds", {get: () => canvas.dimensions.rect});
  }
}

/**
 * An extension of PIXI.Mesh which emulate a PIXI.Sprite with a specific shader.
 * @param {PIXI.Texture} [texture=PIXI.Texture.EMPTY]                 Texture bound to this sprite mesh.
 * @param {typeof BaseSamplerShader} [shaderClass=BaseSamplerShader]  Shader class used by this sprite mesh.
 */
class SpriteMesh extends PIXI.Container {
  constructor(texture, shaderClass=BaseSamplerShader) {
    super();
    // Create shader program
    if ( !foundry.utils.isSubclass(shaderClass, BaseSamplerShader) ) {
      throw new Error("SpriteMesh shader class must be a subclass of BaseSamplerShader.");
    }
    this._shader = shaderClass.create();

    // Initialize other data to emulate sprite
    this.vertexData = this.#geometry.buffers[0].data;
    this.uvs = this.#geometry.buffers[1].data;
    this.indices = this.#geometry.indexBuffer.data;

    this._texture = null;
    this._anchor = new PIXI.ObservablePoint(
      this._onAnchorUpdate,
      this,
      (texture ? texture.defaultAnchor.x : 0),
      (texture ? texture.defaultAnchor.y : 0)
    );
    this.texture = texture;
    this.isSprite = true;

    // Assigning some batch data that will not change during the life of this sprite mesh
    this._batchData.vertexData = this.vertexData;
    this._batchData.indices = this.indices;
    this._batchData.uvs = this.uvs;
    this._batchData.object = this;
  }

  /**
   * A temporary reusable rect.
   * @type {PIXI.Rectangle}
   */
  static #TEMP_RECT = new PIXI.Rectangle();

  /**
   * A temporary reusable point.
   * @type {PIXI.Point}
   */
  static #TEMP_POINT = new PIXI.Point();

  /**
   * Geometry bound to this SpriteMesh.
   * @type {PIXI.Geometry}
   */
  #geometry = new PIXI.Geometry()
    .addAttribute("aVertexPosition", new PIXI.Buffer(new Float32Array(8), false), 2)
    .addAttribute("aTextureCoord", new PIXI.Buffer(new Float32Array(8), true), 2)
    .addIndex([0, 1, 2, 0, 2, 3]);

  /**
   * Snapshot of some parameters of this display object to render in batched mode.
   * @type {{_tintRGB: number, _texture: PIXI.Texture, indices: number[],
   * uvs: number[], blendMode: PIXI.BLEND_MODES, vertexData: number[], worldAlpha: number}}
   * @protected
   */
  _batchData = {
    _texture: undefined,
    vertexData: undefined,
    indices: undefined,
    uvs: undefined,
    worldAlpha: undefined,
    _tintRGB: undefined,
    blendMode: undefined,
    object: undefined
  };

  /**
   * The indices of the geometry.
   * @type {Uint16Array}
   */
  indices;

  /**
   * The width of the sprite (this is initially set by the texture).
   * @type {number}
   * @protected
   */
  _width = 0;

  /**
   * The height of the sprite (this is initially set by the texture)
   * @type {number}
   * @protected
   */
  _height = 0;

  /**
   * The texture that the sprite is using.
   * @type {PIXI.Texture}
   * @protected
   */
  _texture;

  /**
   * The texture ID.
   * @type {number}
   * @protected
   */
  _textureID = -1;

  /**
   * Cached tint value so we can tell when the tint is changed.
   * @type {[red: number, green: number, blue: number, alpha: number]}
   * @protected
   * @internal
   */
  _cachedTint = [1, 1, 1, 1];

  /**
   * The texture trimmed ID.
   * @type {number}
   * @protected
   */
  _textureTrimmedID = -1;

  /**
   * This is used to store the uvs data of the sprite, assigned at the same time
   * as the vertexData in calculateVertices().
   * @type {Float32Array}
   * @protected
   */
  uvs;

  /**
   * The anchor point defines the normalized coordinates
   * in the texture that map to the position of this
   * sprite.
   *
   * By default, this is `(0,0)` (or `texture.defaultAnchor`
   * if you have modified that), which means the position
   * `(x,y)` of this `Sprite` will be the top-left corner.
   *
   * Note: Updating `texture.defaultAnchor` after
   * constructing a `Sprite` does _not_ update its anchor.
   *
   * {@link https://docs.cocos2d-x.org/cocos2d-x/en/sprites/manipulation.html}
   * @type {PIXI.ObservablePoint}
   * @protected
   */
  _anchor;

  /**
   * This is used to store the vertex data of the sprite (basically a quad).
   * @type {Float32Array}
   * @protected
   */
  vertexData;

  /**
   * This is used to calculate the bounds of the object IF it is a trimmed sprite.
   * @type {Float32Array|null}
   * @protected
   */
  vertexTrimmedData = null;

  /**
   * The transform ID.
   * @type {number}
   * @private
   */
  _transformID = -1;

  /**
   * The transform ID.
   * @type {number}
   * @private
   */
  _transformTrimmedID = -1;

  /**
   * The tint applied to the sprite. This is a hex value. A value of 0xFFFFFF will remove any tint effect.
   * @type {PIXI.Color}
   * @protected
   */
  _tintColor = new PIXI.Color(0xFFFFFF);

  /**
   * The tint applied to the sprite. This is a RGB value. A value of 0xFFFFFF will remove any tint effect.
   * @type {number}
   * @protected
   */
  _tintRGB = 0xFFFFFF;

  /**
   * An instance of a texture uvs used for padded SpriteMesh.
   * Instanced only when padding becomes non-zero.
   * @type {PIXI.TextureUvs|null}
   * @protected
   */
  _textureUvs = null;

  /**
   * Used to track a tint or alpha change to execute a recomputation of _cachedTint.
   * @type {boolean}
   * @protected
   */
  _tintAlphaDirty = true;

  /**
   * The PIXI.State of this SpriteMesh.
   * @type {PIXI.State}
   */
  #state = PIXI.State.for2d();

  /* ---------------------------------------- */

  /**
   * The shader bound to this mesh.
   * @type {BaseSamplerShader}
   */
  get shader() {
    return this._shader;
  }

  /**
   * The shader bound to this mesh.
   * @type {BaseSamplerShader}
   * @protected
   */
  _shader;

  /* ---------------------------------------- */

  /**
   * The x padding in pixels (must be a non-negative value.)
   * @type {number}
   */
  get paddingX() {
    return this._paddingX;
  }

  set paddingX(value) {
    if ( value < 0 ) throw new Error("The padding must be a non-negative value.");
    if ( this._paddingX === value ) return;
    this._paddingX = value;
    this._textureID = -1;
    this._textureTrimmedID = -1;
    this._textureUvs ??= new PIXI.TextureUvs();
  }

  /**
   * They y padding in pixels (must be a non-negative value.)
   * @type {number}
   */
  get paddingY() {
    return this._paddingY;
  }

  set paddingY(value) {
    if ( value < 0 ) throw new Error("The padding must be a non-negative value.");
    if ( this._paddingY === value ) return;
    this._paddingY = value;
    this._textureID = -1;
    this._textureTrimmedID = -1;
    this._textureUvs ??= new PIXI.TextureUvs();
  }

  /**
   * The maximum x/y padding in pixels (must be a non-negative value.)
   * @type {number}
   */
  get padding() {
    return Math.max(this._paddingX, this._paddingY);
  }

  set padding(value) {
    if ( value < 0 ) throw new Error("The padding must be a non-negative value.");
    this.paddingX = this.paddingY = value;
  }

  /**
   * @type {number}
   * @protected
   */
  _paddingX = 0;

  /**
   * @type {number}
   * @protected
   */
  _paddingY = 0;

  /* ---------------------------------------- */

  /**
   * The blend mode applied to the SpriteMesh.
   * @type {PIXI.BLEND_MODES}
   * @defaultValue PIXI.BLEND_MODES.NORMAL
   */
  set blendMode(value) {
    this.#state.blendMode = value;
  }

  get blendMode() {
    return this.#state.blendMode;
  }

  /* ---------------------------------------- */

  /**
   * If true PixiJS will Math.round() x/y values when rendering, stopping pixel interpolation.
   * Advantages can include sharper image quality (like text) and faster rendering on canvas.
   * The main disadvantage is movement of objects may appear less smooth.
   * To set the global default, change PIXI.settings.ROUND_PIXELS
   * @defaultValue PIXI.settings.ROUND_PIXELS
   */
  set roundPixels(value) {
    if ( this.#roundPixels !== value ) this._transformID = -1;
    this.#roundPixels = value;
  }

  get roundPixels() {
    return this.#roundPixels;
  }

  #roundPixels = PIXI.settings.ROUND_PIXELS;

  /* ---------------------------------------- */

  /**
   * Used to force an alpha mode on this sprite mesh.
   * If this property is non null, this value will replace the texture alphaMode when computing color channels.
   * Affects how tint, worldAlpha and alpha are computed each others.
   * @type {PIXI.ALPHA_MODES}
   */
  get alphaMode() {
    return this.#alphaMode ?? this._texture.baseTexture.alphaMode;
  }

  set alphaMode(mode) {
    if ( this.#alphaMode === mode ) return;
    this.#alphaMode = mode;
    this._tintAlphaDirty = true;
  }

  #alphaMode = null;

  /* ---------------------------------------- */

  /**
   * Returns the SpriteMesh associated batch plugin. By default the returned plugin is that of the associated shader.
   * If a plugin is forced, it will returns the forced plugin.
   * @type {string}
   */
  get pluginName() {
    return this.#pluginName ?? this._shader.pluginName;
  }

  set pluginName(name) {
    this.#pluginName = name;
  }

  #pluginName = null;

  /* ---------------------------------------- */

  /** @override */
  get width() {
    return Math.abs(this.scale.x) * this._texture.orig.width;
  }

  set width(width) {
    const s = Math.sign(this.scale.x) || 1;
    this.scale.x = s * width / this._texture.orig.width;
    this._width = width;
  }

  /* ---------------------------------------- */

  /** @override */
  get height() {
    return Math.abs(this.scale.y) * this._texture.orig.height;
  }

  set height(height) {
    const s = Math.sign(this.scale.y) || 1;
    this.scale.y = s * height / this._texture.orig.height;
    this._height = height;
  }

  /* ---------------------------------------- */

  /**
   * The texture that the sprite is using.
   * @type {PIXI.Texture}
   */
  get texture() {
    return this._texture;
  }

  set texture(texture) {
    texture = texture ?? null;
    if ( this._texture === texture ) return;
    if ( this._texture ) this._texture.off("update", this._onTextureUpdate, this);

    this._texture = texture || PIXI.Texture.EMPTY;
    this._textureID = this._textureTrimmedID = -1;
    this._tintAlphaDirty = true;

    if ( texture ) {
      if ( this._texture.baseTexture.valid ) this._onTextureUpdate();
      else this._texture.once("update", this._onTextureUpdate, this);
    }
  }

  /* ---------------------------------------- */

  /**
   * The anchor sets the origin point of the sprite. The default value is taken from the {@link PIXI.Texture|Texture}
   * and passed to the constructor.
   *
   * The default is `(0,0)`, this means the sprite's origin is the top left.
   *
   * Setting the anchor to `(0.5,0.5)` means the sprite's origin is centered.
   *
   * Setting the anchor to `(1,1)` would mean the sprite's origin point will be the bottom right corner.
   *
   * If you pass only single parameter, it will set both x and y to the same value as shown in the example below.
   * @type {PIXI.ObservablePoint}
   */
  get anchor() {
    return this._anchor;
  }

  set anchor(anchor) {
    this._anchor.copyFrom(anchor);
  }

  /* ---------------------------------------- */

  /**
   * The tint applied to the sprite. This is a hex value.
   *
   * A value of 0xFFFFFF will remove any tint effect.
   * @type {number}
   * @defaultValue 0xFFFFFF
   */
  get tint() {
    return this._tintColor.value;
  }

  set tint(tint) {
    this._tintColor.setValue(tint);
    const tintRGB = this._tintColor.toLittleEndianNumber();
    if ( tintRGB === this._tintRGB ) return;
    this._tintRGB = tintRGB;
    this._tintAlphaDirty = true;
  }

  /* ---------------------------------------- */

  /**
   * The HTML source element for this SpriteMesh texture.
   * @type {HTMLImageElement|HTMLVideoElement|null}
   */
  get sourceElement() {
    if ( !this.texture.valid ) return null;
    return this.texture?.baseTexture.resource?.source || null;
  }

  /* ---------------------------------------- */

  /**
   * Is this SpriteMesh rendering a video texture?
   * @type {boolean}
   */
  get isVideo() {
    const source = this.sourceElement;
    return source?.tagName === "VIDEO";
  }

  /* ---------------------------------------- */

  /**
   * When the texture is updated, this event will fire to update the scale and frame.
   * @protected
   */
  _onTextureUpdate() {
    this._textureID = this._textureTrimmedID = this._transformID = this._transformTrimmedID = -1;
    if ( this._width ) this.scale.x = Math.sign(this.scale.x) * this._width / this._texture.orig.width;
    if ( this._height ) this.scale.y = Math.sign(this.scale.y) * this._height / this._texture.orig.height;
    // Alpha mode of the texture could have changed
    this._tintAlphaDirty = true;
    this.updateUvs();
  }

  /* ---------------------------------------- */

  /**
   * Called when the anchor position updates.
   * @protected
   */
  _onAnchorUpdate() {
    this._textureID = this._textureTrimmedID = this._transformID = this._transformTrimmedID = -1;
  }

  /* ---------------------------------------- */

  /**
   * Update uvs and push vertices and uv buffers on GPU if necessary.
   */
  updateUvs() {
    if ( this._textureID !== this._texture._updateID ) {
      let textureUvs;
      if ( (this._paddingX !== 0) || (this._paddingY !== 0) ) {
        const texture = this._texture;
        const frame = SpriteMesh.#TEMP_RECT.copyFrom(texture.frame).pad(this._paddingX, this._paddingY);
        textureUvs = this._textureUvs;
        textureUvs.set(frame, texture.baseTexture, texture.rotate);
      } else {
        textureUvs = this._texture._uvs;
      }
      this.uvs.set(textureUvs.uvsFloat32);
      this.#geometry.buffers[1].update();
    }
  }

  /* ---------------------------------------- */

  /**
   * Initialize shader based on the shader class type.
   * @param {typeof BaseSamplerShader} shaderClass    The shader class
   */
  setShaderClass(shaderClass) {
    if ( !foundry.utils.isSubclass(shaderClass, BaseSamplerShader) ) {
      throw new Error("SpriteMesh shader class must inherit from BaseSamplerShader.");
    }
    if ( this._shader.constructor === shaderClass ) return;
    this._shader = shaderClass.create();
  }

  /* ---------------------------------------- */

  /** @override */
  updateTransform() {
    super.updateTransform();

    // We set tintAlphaDirty to true if the worldAlpha has changed
    // It is needed to recompute the _cachedTint vec4 which is a combination of tint and alpha
    if ( this.#worldAlpha !== this.worldAlpha ) {
      this.#worldAlpha = this.worldAlpha;
      this._tintAlphaDirty = true;
    }
  }

  #worldAlpha;

  /* ---------------------------------------- */

  /**
   * Calculates worldTransform * vertices, store it in vertexData.
   */
  calculateVertices() {
    if ( this._transformID === this.transform._worldID && this._textureID === this._texture._updateID ) return;

    // Update uvs if necessary
    this.updateUvs();
    this._transformID = this.transform._worldID;
    this._textureID = this._texture._updateID;

    // Set the vertex data
    const {a, b, c, d, tx, ty} = this.transform.worldTransform;
    const orig = this._texture.orig;
    const trim = this._texture.trim;
    const padX = this._paddingX;
    const padY = this._paddingY;

    let w1; let w0; let h1; let h0;
    if ( trim ) {
      // If the sprite is trimmed and is not a tilingsprite then we need to add the extra
      // space before transforming the sprite coords
      w1 = trim.x - (this._anchor._x * orig.width) - padX;
      w0 = w1 + trim.width + (2 * padX);
      h1 = trim.y - (this._anchor._y * orig.height) - padY;
      h0 = h1 + trim.height + (2 * padY);
    }
    else {
      w1 = (-this._anchor._x * orig.width) - padX;
      w0 = w1 + orig.width + (2 * padX);
      h1 = (-this._anchor._y * orig.height) - padY;
      h0 = h1 + orig.height + (2 * padY);
    }

    const vertexData = this.vertexData;
    vertexData[0] = (a * w1) + (c * h1) + tx;
    vertexData[1] = (d * h1) + (b * w1) + ty;
    vertexData[2] = (a * w0) + (c * h1) + tx;
    vertexData[3] = (d * h1) + (b * w0) + ty;
    vertexData[4] = (a * w0) + (c * h0) + tx;
    vertexData[5] = (d * h0) + (b * w0) + ty;
    vertexData[6] = (a * w1) + (c * h0) + tx;
    vertexData[7] = (d * h0) + (b * w1) + ty;

    if ( this.roundPixels ) {
      const r = PIXI.settings.RESOLUTION;
      for ( let i = 0; i < vertexData.length; ++i ) vertexData[i] = Math.round(vertexData[i] * r) / r;
    }
    this.#geometry.buffers[0].update();
  }

  /* ---------------------------------------- */

  /**
   * Calculates worldTransform * vertices for a non texture with a trim. store it in vertexTrimmedData.
   *
   * This is used to ensure that the true width and height of a trimmed texture is respected.
   */
  calculateTrimmedVertices() {
    if ( !this.vertexTrimmedData ) this.vertexTrimmedData = new Float32Array(8);
    else if ( (this._transformTrimmedID === this.transform._worldID)
      && (this._textureTrimmedID === this._texture._updateID) ) return;

    this._transformTrimmedID = this.transform._worldID;
    this._textureTrimmedID = this._texture._updateID;

    const texture = this._texture;
    const vertexData = this.vertexTrimmedData;
    const orig = texture.orig;
    const anchor = this._anchor;
    const padX = this._paddingX;
    const padY = this._paddingY;

    // Compute the new untrimmed bounds
    const wt = this.transform.worldTransform;
    const a = wt.a;
    const b = wt.b;
    const c = wt.c;
    const d = wt.d;
    const tx = wt.tx;
    const ty = wt.ty;

    const w1 = (-anchor._x * orig.width) - padX;
    const w0 = w1 + orig.width + (2 * padX);
    const h1 = (-anchor._y * orig.height) - padY;
    const h0 = h1 + orig.height + (2 * padY);

    vertexData[0] = (a * w1) + (c * h1) + tx;
    vertexData[1] = (d * h1) + (b * w1) + ty;
    vertexData[2] = (a * w0) + (c * h1) + tx;
    vertexData[3] = (d * h1) + (b * w0) + ty;
    vertexData[4] = (a * w0) + (c * h0) + tx;
    vertexData[5] = (d * h0) + (b * w0) + ty;
    vertexData[6] = (a * w1) + (c * h0) + tx;
    vertexData[7] = (d * h0) + (b * w1) + ty;

    if ( this.roundPixels ) {
      const r = PIXI.settings.RESOLUTION;
      for ( let i = 0; i < vertexData.length; ++i ) vertexData[i] = Math.round(vertexData[i] * r) / r;
    }
  }

  /* ---------------------------------------- */

  /** @override */
  _render(renderer) {
    const pluginName = this.pluginName;
    if ( pluginName ) this.#renderBatched(renderer, pluginName);
    else this.#renderDirect(renderer, this._shader);
  }

  /* ---------------------------------------- */

  /**
   * Render with batching.
   * @param {PIXI.Renderer} renderer    The renderer
   * @param {string} pluginName         The batch renderer
   */
  #renderBatched(renderer, pluginName) {
    this.calculateVertices();
    this._updateBatchData();
    const batchRenderer = renderer.plugins[pluginName];
    renderer.batch.setObjectRenderer(batchRenderer);
    batchRenderer.render(this._batchData);
  }

  /* ---------------------------------------- */

  /**
   * Render without batching.
   * @param {PIXI.Renderer} renderer     The renderer
   * @param {BaseSamplerShader} shader   The shader
   */
  #renderDirect(renderer, shader) {
    this.calculateVertices();
    if ( this._tintAlphaDirty ) {
      PIXI.Color.shared.setValue(this._tintColor)
        .premultiply(this.worldAlpha, this.alphaMode > 0)
        .toArray(this._cachedTint);
      this._tintAlphaDirty = false;
    }
    shader._preRender(this, renderer);
    renderer.batch.flush();
    renderer.shader.bind(shader);
    renderer.state.set(this.#state);
    renderer.geometry.bind(this.#geometry, shader);
    renderer.geometry.draw(PIXI.DRAW_MODES.TRIANGLES, 6, 0);
  }

  /* ---------------------------------------- */

  /**
   * Update the batch data object.
   * @protected
   */
  _updateBatchData() {
    this._batchData._texture = this._texture;
    this._batchData.worldAlpha = this.worldAlpha;
    this._batchData._tintRGB = this._tintRGB;
    this._batchData.blendMode = this.#state.blendMode;
  }

  /* ---------------------------------------- */

  /** @override */
  _calculateBounds() {
    const trim = this._texture.trim;
    const orig = this._texture.orig;

    // First lets check to see if the current texture has a trim.
    if ( !trim || ((trim.width === orig.width) && (trim.height === orig.height)) ) {
      this.calculateVertices();
      this._bounds.addQuad(this.vertexData);
    }
    else {
      this.calculateTrimmedVertices();
      this._bounds.addQuad(this.vertexTrimmedData);
    }
  }

  /* ---------------------------------------- */

  /** @override */
  getLocalBounds(rect) {
    // Fast local bounds computation if the sprite has no children!
    if ( this.children.length === 0 ) {
      if ( !this._localBounds ) this._localBounds = new PIXI.Bounds();

      const padX = this._paddingX;
      const padY = this._paddingY;
      const orig = this._texture.orig;
      this._localBounds.minX = (orig.width * -this._anchor._x) - padX;
      this._localBounds.minY = (orig.height * -this._anchor._y) - padY;
      this._localBounds.maxX = (orig.width * (1 - this._anchor._x)) + padX;
      this._localBounds.maxY = (orig.height * (1 - this._anchor._y)) + padY;

      if ( !rect ) {
        if ( !this._localBoundsRect ) this._localBoundsRect = new PIXI.Rectangle();
        rect = this._localBoundsRect;
      }

      return this._localBounds.getRectangle(rect);
    }

    return super.getLocalBounds(rect);
  }

  /* ---------------------------------------- */

  /** @override */
  containsPoint(point) {
    const tempPoint = SpriteMesh.#TEMP_POINT;
    this.worldTransform.applyInverse(point, tempPoint);

    const width = this._texture.orig.width;
    const height = this._texture.orig.height;
    const x1 = -width * this.anchor.x;
    let y1 = 0;

    if ( (tempPoint.x >= x1) && (tempPoint.x < (x1 + width)) ) {
      y1 = -height * this.anchor.y;
      if ( (tempPoint.y >= y1) && (tempPoint.y < (y1 + height)) ) return true;
    }
    return false;
  }

  /* ---------------------------------------- */

  /** @override */
  destroy(options) {
    super.destroy(options);
    this.#geometry.dispose();
    this.#geometry = null;
    this._shader = null;
    this.#state = null;
    this.uvs = null;
    this.indices = null;
    this.vertexData = null;
    this._texture.off("update", this._onTextureUpdate, this);
    this._anchor = null;
    const destroyTexture = (typeof options === "boolean" ? options : options?.texture);
    if ( destroyTexture ) {
      const destroyBaseTexture = (typeof options === "boolean" ? options : options?.baseTexture);
      this._texture.destroy(!!destroyBaseTexture);
    }
    this._texture = null;
  }

  /* ---------------------------------------- */

  /**
   * Create a SpriteMesh from another source.
   * You can specify texture options and a specific shader class derived from BaseSamplerShader.
   * @param {string|PIXI.Texture|HTMLCanvasElement|HTMLVideoElement} source  Source to create texture from.
   * @param {object} [textureOptions]               See {@link PIXI.BaseTexture}'s constructor for options.
   * @param {BaseSamplerShader} [shaderClass]       The shader class to use. BaseSamplerShader by default.
   * @returns {SpriteMesh}
   */
  static from(source, textureOptions, shaderClass) {
    const texture = source instanceof PIXI.Texture ? source : PIXI.Texture.from(source, textureOptions);
    return new SpriteMesh(texture, shaderClass);
  }
}

/**
 * UnboundContainers behave like PIXI.Containers except that they are not bound to their parent's transforms.
 * However, they normally propagate their own transformations to their children.
 */
class UnboundContainer extends PIXI.Container {
  constructor(...args) {
    super(...args);
    // Replacing PIXI.Transform with an UnboundTransform
    this.transform = new UnboundTransform();
  }
}

/* -------------------------------------------- */

/**
 * A custom Transform class which is not bound to the parent worldTransform.
 * localTransform are working as usual.
 */
class UnboundTransform extends PIXI.Transform {
  /** @override */
  static IDENTITY = new UnboundTransform();

  /* -------------------------------------------- */

  /** @override */
  updateTransform(parentTransform) {
    const lt = this.localTransform;

    if ( this._localID !== this._currentLocalID ) {
      // Get the matrix values of the displayobject based on its transform properties..
      lt.a = this._cx * this.scale.x;
      lt.b = this._sx * this.scale.x;
      lt.c = this._cy * this.scale.y;
      lt.d = this._sy * this.scale.y;

      lt.tx = this.position.x - ((this.pivot.x * lt.a) + (this.pivot.y * lt.c));
      lt.ty = this.position.y - ((this.pivot.x * lt.b) + (this.pivot.y * lt.d));
      this._currentLocalID = this._localID;

      // Force an update
      this._parentID = -1;
    }

    if ( this._parentID !== parentTransform._worldID ) {
      // We don't use the values from the parent transform. We're just updating IDs.
      this._parentID = parentTransform._worldID;
      this._worldID++;
    }
  }
}

/**
 * @typedef {Object} CanvasAnimationAttribute
 * @property {string} attribute             The attribute name being animated
 * @property {Object} parent                The object within which the attribute is stored
 * @property {number} to                    The destination value of the attribute
 * @property {number} [from]                An initial value of the attribute, otherwise parent[attribute] is used
 * @property {number} [delta]               The computed delta between to and from
 * @property {number} [done]                The amount of the total delta which has been animated
 * @property {boolean} [color]              Is this a color animation that applies to RGB channels
 */

/**
 * @typedef {Object} CanvasAnimationOptions
 * @property {PIXI.DisplayObject} [context] A DisplayObject which defines context to the PIXI.Ticker function
 * @property {string|symbol} [name]         A unique name which can be used to reference the in-progress animation
 * @property {number} [duration]            A duration in milliseconds over which the animation should occur
 * @property {number} [priority]            A priority in PIXI.UPDATE_PRIORITY which defines when the animation
 *                                          should be evaluated related to others
 * @property {Function|string} [easing]     An easing function used to translate animation time or the string name
 *                                          of a static member of the CanvasAnimation class
 * @property {function(number, CanvasAnimationData)} [ontick] A callback function which fires after every frame
 * @property {Promise} [wait]              The animation isn't started until this promise resolves
 */

/**
 * @typedef {Object} _CanvasAnimationData
 * @property {Function} fn                  The animation function being executed each frame
 * @property {number} time                  The current time of the animation, in milliseconds
 * @property {CanvasAnimationAttribute[]} attributes  The attributes being animated
 * @property {number} state                 The current state of the animation (see {@link CanvasAnimation.STATES})
 * @property {Promise} promise              A Promise which resolves once the animation is complete
 * @property {Function} resolve             The resolution function, allowing animation to be ended early
 * @property {Function} reject              The rejection function, allowing animation to be ended early
 */

/**
 * @typedef {_CanvasAnimationData & CanvasAnimationOptions} CanvasAnimationData
 */

/**
 * A helper class providing utility methods for PIXI Canvas animation
 */
class CanvasAnimation {

  /**
   * The possible states of an animation.
   * @enum {number}
   */
  static get STATES() {
    return this.#STATES;
  }

  static #STATES = Object.freeze({

    /**
     * An error occurred during waiting or running the animation.
     */
    FAILED: -2,

    /**
     * The animation was terminated before it could complete.
     */
    TERMINATED: -1,

    /**
     * Waiting for the wait promise before the animation is started.
     */
    WAITING: 0,

    /**
     * The animation has been started and is running.
     */
    RUNNING: 1,

    /**
     * The animation was completed without errors and without being terminated.
     */
    COMPLETED: 2
  });

  /* -------------------------------------------- */

  /**
   * The ticker used for animations.
   * @type {PIXI.Ticker}
   */
  static get ticker() {
    return canvas.app.ticker;
  }

  /* -------------------------------------------- */

  /**
   * Track an object of active animations by name, context, and function
   * This allows a currently playing animation to be referenced and terminated
   * @type {Record<string, CanvasAnimationData>}
   */
  static animations = {};

  /* -------------------------------------------- */

  /**
   * Apply an animation from the current value of some attribute to a new value
   * Resolve a Promise once the animation has concluded and the attributes have reached their new target
   *
   * @param {CanvasAnimationAttribute[]} attributes   An array of attributes to animate
   * @param {CanvasAnimationOptions} options          Additional options which customize the animation
   *
   * @returns {Promise<boolean>}                      A Promise which resolves to true once the animation has concluded
   *                                                  or false if the animation was prematurely terminated
   *
   * @example Animate Token Position
   * ```js
   * let animation = [
   *   {
   *     parent: token,
   *     attribute: "x",
   *     to: 1000
   *   },
   *   {
   *     parent: token,
   *     attribute: "y",
   *     to: 2000
   *   }
   * ];
   * CanvasAnimation.animate(attributes, {duration:500});
   * ```
   */
  static async animate(attributes, {context=canvas.stage, name, duration=1000, easing, ontick, priority, wait}={}) {
    priority ??= PIXI.UPDATE_PRIORITY.LOW + 1;
    if ( typeof easing === "string" ) easing = this[easing];

    // If an animation with this name already exists, terminate it
    if ( name ) this.terminateAnimation(name);

    // Define the animation and its animation function
    attributes = attributes.map(a => {
      a.from = a.from ?? a.parent[a.attribute];
      a.delta = a.to - a.from;
      a.done = 0;

      // Special handling for color transitions
      if ( a.to instanceof Color ) {
        a.color = true;
        a.from = Color.from(a.from);
      }
      return a;
    });
    if ( attributes.length && attributes.every(a => a.delta === 0) ) return;
    const animation = {attributes, context, duration, easing, name, ontick, time: 0, wait,
      state: CanvasAnimation.STATES.WAITING};
    animation.fn = dt => CanvasAnimation.#animateFrame(dt, animation);

    // Create a promise which manages the animation lifecycle
    const promise = new Promise(async (resolve, reject) => {
      animation.resolve = completed => {
        if ( (animation.state === CanvasAnimation.STATES.WAITING)
          || (animation.state === CanvasAnimation.STATES.RUNNING) ) {
          animation.state = completed ? CanvasAnimation.STATES.COMPLETED : CanvasAnimation.STATES.TERMINATED;
          resolve(completed);
        }
      };
      animation.reject = error => {
        if ( (animation.state === CanvasAnimation.STATES.WAITING)
          || (animation.state === CanvasAnimation.STATES.RUNNING) ) {
          animation.state = CanvasAnimation.STATES.FAILED;
          reject(error);
        }
      };
      try {
        if ( wait instanceof Promise ) await wait;
        if ( animation.state === CanvasAnimation.STATES.WAITING ) {
          animation.state = CanvasAnimation.STATES.RUNNING;
          this.ticker.add(animation.fn, context, priority);
        }
      } catch(err) {
        animation.reject(err);
      }
    })

    // Log any errors
      .catch(err => console.error(err))

    // Remove the animation once completed
      .finally(() => {
        this.ticker.remove(animation.fn, context);
        if ( name && (this.animations[name] === animation) ) delete this.animations[name];
      });

    // Record the animation and return
    if ( name ) {
      animation.promise = promise;
      this.animations[name] = animation;
    }
    return promise;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve an animation currently in progress by its name
   * @param {string} name             The animation name to retrieve
   * @returns {CanvasAnimationData}   The animation data, or undefined
   */
  static getAnimation(name) {
    return this.animations[name];
  }

  /* -------------------------------------------- */

  /**
   * If an animation using a certain name already exists, terminate it
   * @param {string} name       The animation name to terminate
   */
  static terminateAnimation(name) {
    let animation = this.animations[name];
    if (animation) animation.resolve(false);
  }

  /* -------------------------------------------- */

  /**
   * Cosine based easing with smooth in-out.
   * @param {number} pt     The proportional animation timing on [0,1]
   * @returns {number}      The eased animation progress on [0,1]
   */
  static easeInOutCosine(pt) {
    return (1 - Math.cos(Math.PI * pt)) * 0.5;
  }

  /* -------------------------------------------- */

  /**
   * Shallow ease out.
   * @param {number} pt     The proportional animation timing on [0,1]
   * @returns {number}      The eased animation progress on [0,1]
   */
  static easeOutCircle(pt) {
    return Math.sqrt(1 - Math.pow(pt - 1, 2));
  }

  /* -------------------------------------------- */

  /**
   * Shallow ease in.
   * @param {number} pt     The proportional animation timing on [0,1]
   * @returns {number}      The eased animation progress on [0,1]
   */
  static easeInCircle(pt) {
    return 1 - Math.sqrt(1 - Math.pow(pt, 2));
  }

  /* -------------------------------------------- */

  /**
   * Generic ticker function to implement the animation.
   * This animation wrapper executes once per frame for the duration of the animation event.
   * Once the animated attributes have converged to their targets, it resolves the original Promise.
   * The user-provided ontick function runs each frame update to apply additional behaviors.
   *
   * @param {number} deltaTime                The incremental time which has elapsed
   * @param {CanvasAnimationData} animation   The animation which is being performed
   */
  static #animateFrame(deltaTime, animation) {
    const {attributes, duration, ontick} = animation;

    // Compute animation timing and progress
    const dt = this.ticker.elapsedMS;     // Delta time in MS
    animation.time += dt;                 // Total time which has elapsed
    const complete = animation.time >= duration;
    const pt = complete ? 1 : animation.time / duration; // Proportion of total duration
    const pa = animation.easing ? animation.easing(pt) : pt;

    // Update each attribute
    try {
      for ( let a of attributes ) CanvasAnimation.#updateAttribute(a, pa);
      if ( ontick ) ontick(dt, animation);
    }

    // Terminate the animation if any errors occur
    catch(err) {
      animation.reject(err);
    }

    // Resolve the original promise once the animation is complete
    if ( complete ) animation.resolve(true);
  }

  /* -------------------------------------------- */

  /**
   * Update a single attribute according to its animation completion percentage
   * @param {CanvasAnimationAttribute} attribute    The attribute being animated
   * @param {number} percentage                     The animation completion percentage
   */
  static #updateAttribute(attribute, percentage) {
    attribute.done = attribute.delta * percentage;

    // Complete animation
    if ( percentage === 1 ) {
      attribute.parent[attribute.attribute] = attribute.to;
      return;
    }

    // Color animation
    if ( attribute.color ) {
      attribute.parent[attribute.attribute] = attribute.from.mix(attribute.to, percentage);
      return;
    }

    // Numeric attribute
    attribute.parent[attribute.attribute] = attribute.from + attribute.done;
  }
}

/**
 * A generic helper for drawing a standard Control Icon
 * @type {PIXI.Container}
 */
class ControlIcon extends PIXI.Container {
  constructor({texture, size=40, borderColor=0xFF5500, tint=null, elevation=0}={}, ...args) {
    super(...args);

    // Define arguments
    this.iconSrc = texture;
    this.size = size;
    this.rect = [-2, -2, size+4, size+4];
    this.borderColor = borderColor;

    /**
     * The color of the icon tint, if any
     * @type {number|null}
     */
    this.tintColor = tint;

    // Define hit area
    this.eventMode = "static";
    this.interactiveChildren = false;
    this.hitArea = new PIXI.Rectangle(...this.rect);
    this.cursor = "pointer";

    // Background
    this.bg = this.addChild(new PIXI.Graphics());
    this.bg.clear().beginFill(0x000000, 0.4).lineStyle(2, 0x000000, 1.0).drawRoundedRect(...this.rect, 5).endFill();

    // Icon
    this.icon = this.addChild(new PIXI.Sprite());

    // Border
    this.border = this.addChild(new PIXI.Graphics());
    this.border.visible = false;

    // Elevation
    this.tooltip = this.addChild(new PreciseText());
    this.tooltip.visible = false;

    // Set the initial elevation
    this.elevation = elevation;

    // Draw asynchronously
    this.draw();
  }

  /* -------------------------------------------- */

  /**
   * The elevation of the ControlIcon, which is displayed in its tooltip text.
   * @type {number}
   */
  get elevation() {
    return this.#elevation;
  }

  set elevation(value) {
    if ( (typeof value !== "number") || !Number.isFinite(value) ) {
      throw new Error("ControlIcon#elevation must be a finite numeric value.");
    }
    if ( value === this.#elevation ) return;
    this.#elevation = value;
    this.tooltip.text = `${value > 0 ? "+" : ""}${value} ${canvas.grid.units}`.trim();
    this.tooltip.visible = value !== 0;
  }

  #elevation = 0;

  /* -------------------------------------------- */

  /**
   * Initial drawing of the ControlIcon
   * @returns {Promise<ControlIcon>}
   */
  async draw() {
    if ( this.destroyed ) return this;
    this.texture = this.texture ?? await loadTexture(this.iconSrc);
    this.icon.texture = this.texture;
    this.icon.width = this.icon.height = this.size;
    this.tooltip.style = CONFIG.canvasTextStyle;
    this.tooltip.anchor.set(0.5, 1);
    this.tooltip.position.set(this.size / 2, -12);
    return this.refresh();
  }

  /* -------------------------------------------- */

  /**
   * Incremental refresh for ControlIcon appearance.
   */
  refresh({visible, iconColor, borderColor, borderVisible}={}) {
    if ( iconColor !== undefined ) this.tintColor = iconColor;
    this.icon.tint = this.tintColor ?? 0xFFFFFF;
    if ( borderColor !== undefined ) this.borderColor = borderColor;
    this.border.clear().lineStyle(2, this.borderColor, 1.0).drawRoundedRect(...this.rect, 5).endFill();
    if ( borderVisible !== undefined ) this.border.visible = borderVisible;
    if ( visible !== undefined && (this.visible !== visible) ) {
      this.visible = visible;
      MouseInteractionManager.emulateMoveEvent();
    }
    return this;
  }
}

/**
 * Handle mouse interaction events for a Canvas object.
 * There are three phases of events: hover, click, and drag
 *
 * Hover Events:
 * _handlePointerOver
 *  action: hoverIn
 * _handlePointerOut
 *  action: hoverOut
 *
 * Left Click and Double-Click
 * _handlePointerDown
 *  action: clickLeft
 *  action: clickLeft2
 *  action: unclickLeft
 *
 * Right Click and Double-Click
 * _handleRightDown
 *  action: clickRight
 *  action: clickRight2
 *  action: unclickRight
 *
 * Drag and Drop
 * _handlePointerMove
 *  action: dragLeftStart
 *  action: dragRightStart
 *  action: dragLeftMove
 *  action: dragRightMove
 * _handlePointerUp
 *  action: dragLeftDrop
 *  action: dragRightDrop
 * _handleDragCancel
 *  action: dragLeftCancel
 *  action: dragRightCancel
 */
class MouseInteractionManager {
  constructor(object, layer, permissions={}, callbacks={}, options={}) {
    this.object = object;
    this.layer = layer;
    this.permissions = permissions;
    this.callbacks = callbacks;

    /**
     * Interaction options which configure handling workflows
     * @type {{target: PIXI.DisplayObject, dragResistance: number}}
     */
    this.options = options;

    /**
     * The current interaction state
     * @type {number}
     */
    this.state = this.states.NONE;

    /**
     * Bound interaction data object to populate with custom data.
     * @type {Record<string, any>}
     */
    this.interactionData = {};

    /**
     * The drag handling time
     * @type {number}
     */
    this.dragTime = 0;

    /**
     * The time of the last left-click event
     * @type {number}
     */
    this.lcTime = 0;

    /**
     * The time of the last right-click event
     * @type {number}
     */
    this.rcTime = 0;

    /**
     * A flag for whether we are right-click dragging
     * @type {boolean}
     */
    this._dragRight = false;

    /**
     * An optional ControlIcon instance for the object
     * @type {ControlIcon|null}
     */
    this.controlIcon = this.options.target ? this.object[this.options.target] : null;

    /**
     * The view id pertaining to the PIXI Application.
     * If not provided, default to canvas.app.view.id
     * @type {string}
     */
    this.viewId = (this.options.application ?? canvas.app).view.id;
  }

  /**
   * The client position of the last left/right-click.
   * @type {PIXI.Point}
   */
  lastClick = new PIXI.Point();

  /**
   * Bound handlers which can be added and removed
   * @type {Record<string, Function>}
   */
  #handlers = {};

  /**
   * Enumerate the states of a mouse interaction workflow.
   * 0: NONE - the object is inactive
   * 1: HOVER - the mouse is hovered over the object
   * 2: CLICKED - the object is clicked
   * 3: GRABBED - the object is grabbed
   * 4: DRAG - the object is being dragged
   * 5: DROP - the object is being dropped
   * @enum {number}
   */
  static INTERACTION_STATES = {
    NONE: 0,
    HOVER: 1,
    CLICKED: 2,
    GRABBED: 3,
    DRAG: 4,
    DROP: 5
  };

  /**
   * Enumerate the states of handle outcome.
   * -2: SKIPPED - the handler has been skipped by previous logic
   * -1: DISALLOWED - the handler has dissallowed further process
   *  1: REFUSED - the handler callback has been processed and is refusing further process
   *  2: ACCEPTED - the handler callback has been processed and is accepting further process
   * @enum {number}
   */
  static #HANDLER_OUTCOME = {
    SKIPPED: -2,
    DISALLOWED: -1,
    REFUSED: 1,
    ACCEPTED: 2
  };

  /**
   * The maximum number of milliseconds between two clicks to be considered a double-click.
   * @type {number}
   */
  static DOUBLE_CLICK_TIME_MS = 250;

  /**
   * The maximum number of pixels between two clicks to be considered a double-click.
   * @type {number}
   */
  static DOUBLE_CLICK_DISTANCE_PX = 5;

  /**
   * The number of milliseconds of mouse click depression to consider it a long press.
   * @type {number}
   */
  static LONG_PRESS_DURATION_MS = 500;

  /**
   * Global timeout for the long-press event.
   * @type {number|null}
   */
  static longPressTimeout = null;

  /* -------------------------------------------- */

  /**
   * Emulate a pointermove event. Needs to be called when an object with the static event mode
   * or any of its parents is transformed or its visibility is changed.
   */
  static emulateMoveEvent() {
    MouseInteractionManager.#emulateMoveEvent();
  }

  static #emulateMoveEvent = foundry.utils.throttle(() => {
    const events = canvas.app.renderer.events;
    const rootPointerEvent = events.rootPointerEvent;
    if ( !events.supportsPointerEvents ) return;
    if ( events.supportsTouchEvents && (rootPointerEvent.pointerType === "touch") ) return;
    events.domElement.dispatchEvent(new PointerEvent("pointermove", {
      pointerId: rootPointerEvent.pointerId,
      pointerType: rootPointerEvent.pointerType,
      isPrimary: rootPointerEvent.isPrimary,
      clientX: rootPointerEvent.clientX,
      clientY: rootPointerEvent.clientY,
      pageX: rootPointerEvent.pageX,
      pageY: rootPointerEvent.pageY,
      altKey: rootPointerEvent.altKey,
      ctrlKey: rootPointerEvent.ctrlKey,
      metaKey: rootPointerEvent.metaKey,
      shiftKey: rootPointerEvent.shiftKey
    }));
  }, 10);

  /* -------------------------------------------- */

  /**
   * Get the target.
   * @type {PIXI.DisplayObject}
   */
  get target() {
    return this.options.target ? this.object[this.options.target] : this.object;
  }

  /**
   * Is this mouse manager in a dragging state?
   * @type {boolean}
   */
  get isDragging() {
    return this.state >= this.states.DRAG;
  }

  /* -------------------------------------------- */

  /**
   * Activate interactivity for the handled object
   */
  activate() {

    // Remove existing listeners
    this.state = this.states.NONE;
    this.target.removeAllListeners();

    // Create bindings for all handler functions
    this.#handlers = {
      pointerover: this.#handlePointerOver.bind(this),
      pointerout: this.#handlePointerOut.bind(this),
      pointerdown: this.#handlePointerDown.bind(this),
      pointermove: this.#handlePointerMove.bind(this),
      pointerup: this.#handlePointerUp.bind(this),
      contextmenu: this.#handleDragCancel.bind(this)
    };

    // Activate hover events to start the workflow
    this.#activateHoverEvents();

    // Set the target as interactive
    this.target.eventMode = "static";
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Test whether the current user has permission to perform a step of the workflow
   * @param {string} action     The action being attempted
   * @param {Event|PIXI.FederatedEvent} event The event being handled
   * @returns {boolean}         Can the action be performed?
   */
  can(action, event) {
    const fn = this.permissions[action];
    if ( typeof fn === "boolean" ) return fn;
    if ( fn instanceof Function ) return fn.call(this.object, game.user, event);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Execute a callback function associated with a certain action in the workflow
   * @param {string} action     The action being attempted
   * @param {Event|PIXI.FederatedEvent} event The event being handled
   * @param {...*} args         Additional callback arguments.
   * @returns {boolean}         A boolean which may indicate that the event was handled by the callback.
   *                            Events which do not specify a callback are assumed to have been handled as no-op.
   */
  callback(action, event, ...args) {
    const fn = this.callbacks[action];
    if ( fn instanceof Function ) {
      this.#assignInteractionData(event);
      return fn.call(this.object, event, ...args) ?? true;
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * A reference to the possible interaction states which can be observed
   * @returns {Record<string, number>}
   */
  get states() {
    return this.constructor.INTERACTION_STATES;
  }

  /* -------------------------------------------- */

  /**
   * A reference to the possible interaction states which can be observed
   * @returns {Record<string, number>}
   */
  get handlerOutcomes() {
    return MouseInteractionManager.#HANDLER_OUTCOME;
  }

  /* -------------------------------------------- */
  /*  Listener Activation and Deactivation        */
  /* -------------------------------------------- */

  /**
   * Activate a set of listeners which handle hover events on the target object
   */
  #activateHoverEvents() {
    // Disable and re-register mouseover and mouseout handlers
    this.target.off("pointerover", this.#handlers.pointerover).on("pointerover", this.#handlers.pointerover);
    this.target.off("pointerout", this.#handlers.pointerout).on("pointerout", this.#handlers.pointerout);
  }

  /* -------------------------------------------- */

  /**
   * Activate a new set of listeners for click events on the target object.
   */
  #activateClickEvents() {
    this.#deactivateClickEvents();
    this.target.on("pointerdown", this.#handlers.pointerdown);
    this.target.on("pointerup", this.#handlers.pointerup);
    this.target.on("pointerupoutside", this.#handlers.pointerup);
  }

  /* -------------------------------------------- */

  /**
   * Deactivate event listeners for click events on the target object.
   */
  #deactivateClickEvents() {
    this.target.off("pointerdown", this.#handlers.pointerdown);
    this.target.off("pointerup", this.#handlers.pointerup);
    this.target.off("pointerupoutside", this.#handlers.pointerup);
  }

  /* -------------------------------------------- */

  /**
   * Activate events required for handling a drag-and-drop workflow
   */
  #activateDragEvents() {
    this.#deactivateDragEvents();
    this.layer.on("pointermove", this.#handlers.pointermove);
    if ( !this._dragRight ) {
      canvas.app.view.addEventListener("contextmenu", this.#handlers.contextmenu, {capture: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Deactivate events required for handling drag-and-drop workflow.
   * @param {boolean} [silent]      Set to true to activate the silent mode.
   */
  #deactivateDragEvents(silent) {
    this.layer.off("pointermove", this.#handlers.pointermove);
    canvas.app.view.removeEventListener("contextmenu", this.#handlers.contextmenu, {capture: true});
  }

  /* -------------------------------------------- */
  /*  Hover In and Hover Out                      */
  /* -------------------------------------------- */

  /**
   * Handle mouse-over events which activate downstream listeners and do not stop propagation.
   * @param {PIXI.FederatedEvent} event
   */
  #handlePointerOver(event) {
    const action = "hoverIn";
    if ( (this.state !== this.states.NONE) || (event.nativeEvent && (event.nativeEvent.target.id !== this.viewId)) ) {
      return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
    }
    if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);

    // Invoke the callback function
    this.state = this.states.HOVER;
    if ( this.callback(action, event) === false ) {
      this.state = this.states.NONE;
      return this.#debug(action, event, this.handlerOutcomes.REFUSED);
    }

    // Activate click events
    this.#activateClickEvents();
    return this.#debug(action, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-out events which terminate hover workflows and do not stop propagation.
   * @param {PIXI.FederatedEvent} event
   */
  #handlePointerOut(event) {
    if ( event.pointerType === "touch" ) return; // Ignore Touch events
    const action = "hoverOut";
    if ( !this.state.between(this.states.HOVER, this.states.CLICKED)
      || (event.nativeEvent && (event.nativeEvent.target.id !== this.viewId) ) ) {
      return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
    }
    if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);

    // Was the mouse-out event handled by the callback?
    const priorState = this.state;
    this.state = this.states.NONE;
    if ( this.callback(action, event) === false ) {
      this.state = priorState;
      return this.#debug(action, event, this.handlerOutcomes.REFUSED);
    }

    // Deactivate click events
    this.#deactivateClickEvents();
    return this.#debug(action, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-down events which activate downstream listeners.
   * @param {PIXI.FederatedEvent} event
   */
  #handlePointerDown(event) {
    if ( event.button === 0 ) return this.#handleLeftDown(event);
    if ( event.button === 2 ) return this.#handleRightDown(event);
  }

  /* -------------------------------------------- */
  /*  Left Click and Double Click                 */
  /* -------------------------------------------- */

  /**
   * Handle left-click mouse-down events.
   * Stop further propagation only if the event is allowed by either single or double-click.
   * @param {PIXI.FederatedEvent} event
   */
  #handleLeftDown(event) {
    if ( !this.state.between(this.states.HOVER, this.states.DRAG) ) return;

    // Determine double vs single click
    const isDouble = ((event.timeStamp - this.lcTime) <= MouseInteractionManager.DOUBLE_CLICK_TIME_MS)
      && (Math.hypot(event.clientX - this.lastClick.x, event.clientY - this.lastClick.y)
        <= MouseInteractionManager.DOUBLE_CLICK_DISTANCE_PX);
    this.lcTime = isDouble ? 0 : event.timeStamp;
    this.lastClick.set(event.clientX, event.clientY);

    // Set the origin point from layer local position
    this.interactionData.origin = event.getLocalPosition(this.layer);

    // Activate a timeout to detect long presses
    if ( !isDouble ) {
      clearTimeout(this.constructor.longPressTimeout);
      this.constructor.longPressTimeout = setTimeout(() => {
        this.#handleLongPress(event, this.interactionData.origin);
      }, MouseInteractionManager.LONG_PRESS_DURATION_MS);
    }

    // Dispatch to double and single-click handlers
    if ( isDouble && this.can("clickLeft2", event) ) return this.#handleClickLeft2(event);
    else return this.#handleClickLeft(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-down which trigger a single left-click workflow.
   * @param {PIXI.FederatedEvent} event
   */
  #handleClickLeft(event) {
    const action = "clickLeft";
    if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
    this._dragRight = false;

    // Was the left-click event handled by the callback?
    const priorState = this.state;
    if ( this.state === this.states.HOVER ) this.state = this.states.CLICKED;
    canvas.currentMouseManager = this;
    if ( this.callback(action, event) === false ) {
      this.state = priorState;
      canvas.currentMouseManager = null;
      return this.#debug(action, event, this.handlerOutcomes.REFUSED);
    }

    // Activate drag event handlers
    if ( (this.state === this.states.CLICKED) && this.can("dragStart", event) ) {
      this.state = this.states.GRABBED;
      this.#activateDragEvents();
    }
    return this.#debug(action, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-down which trigger a single left-click workflow.
   * @param {PIXI.FederatedEvent} event
   */
  #handleClickLeft2(event) {
    const action = "clickLeft2";
    if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
    return this.#debug(action, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle a long mouse depression to trigger a long-press workflow.
   * @param {PIXI.FederatedEvent}   event   The mousedown event.
   * @param {PIXI.Point}            origin  The original canvas coordinates of the mouse click
   */
  #handleLongPress(event, origin) {
    const action = "longPress";
    if ( this.callback(action, event, origin) === false ) {
      return this.#debug(action, event, this.handlerOutcomes.REFUSED);
    }
    return this.#debug(action, event);
  }

  /* -------------------------------------------- */
  /*  Right Click and Double Click                */
  /* -------------------------------------------- */

  /**
   * Handle right-click mouse-down events.
   * Stop further propagation only if the event is allowed by either single or double-click.
   * @param {PIXI.FederatedEvent} event
   */
  #handleRightDown(event) {
    if ( !this.state.between(this.states.HOVER, this.states.DRAG) ) return;

    // Determine double vs single click
    const isDouble = ((event.timeStamp - this.rcTime) <= MouseInteractionManager.DOUBLE_CLICK_TIME_MS)
    && (Math.hypot(event.clientX - this.lastClick.x, event.clientY - this.lastClick.y)
        <= MouseInteractionManager.DOUBLE_CLICK_DISTANCE_PX);
    this.rcTime = isDouble ? 0 : event.timeStamp;
    this.lastClick.set(event.clientX, event.clientY);

    // Update event data
    this.interactionData.origin = event.getLocalPosition(this.layer);

    // Dispatch to double and single-click handlers
    if ( isDouble && this.can("clickRight2", event) ) return this.#handleClickRight2(event);
    else return this.#handleClickRight(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle single right-click actions.
   * @param {PIXI.FederatedEvent} event
   */
  #handleClickRight(event) {
    const action = "clickRight";
    if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
    this._dragRight = true;

    // Was the right-click event handled by the callback?
    const priorState = this.state;
    if ( this.state === this.states.HOVER ) this.state = this.states.CLICKED;
    canvas.currentMouseManager = this;
    if ( this.callback(action, event) === false ) {
      this.state = priorState;
      canvas.currentMouseManager = null;
      return this.#debug(action, event, this.handlerOutcomes.REFUSED);
    }

    // Activate drag event handlers
    if ( (this.state === this.states.CLICKED) && this.can("dragRight", event) ) {
      this.state = this.states.GRABBED;
      this.#activateDragEvents();
    }
    return this.#debug(action, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle double right-click actions.
   * @param {PIXI.FederatedEvent} event
   */
  #handleClickRight2(event) {
    const action = "clickRight2";
    if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
    return this.#debug(action, event);
  }

  /* -------------------------------------------- */
  /*  Drag and Drop                               */
  /* -------------------------------------------- */

  /**
   * Handle mouse movement during a drag workflow
   * @param {PIXI.FederatedEvent} event
   */
  #handlePointerMove(event) {
    if ( !this.state.between(this.states.GRABBED, this.states.DRAG) ) return;

    // Limit dragging to 60 updates per second
    const now = Date.now();
    if ( (now - this.dragTime) < canvas.app.ticker.elapsedMS ) return;
    this.dragTime = now;

    // Update interaction data
    const data = this.interactionData;
    data.destination = event.getLocalPosition(this.layer, data.destination);

    // Handling rare case when origin is not defined
    // FIXME: The root cause should be identified and this code removed
    if ( data.origin === undefined ) data.origin = new PIXI.Point().copyFrom(data.destination);

    // Begin a new drag event
    if ( this.state !== this.states.DRAG ) {
      const dx = data.destination.x - data.origin.x;
      const dy = data.destination.y - data.origin.y;
      const dz = Math.hypot(dx, dy);
      const r = this.options.dragResistance || (canvas.dimensions.size / 4);
      if ( dz >= r ) this.#handleDragStart(event);
    }

    // Continue a drag event
    if ( this.state === this.states.DRAG ) this.#handleDragMove(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle the beginning of a new drag start workflow, moving all controlled objects on the layer
   * @param {PIXI.FederatedEvent} event
   */
  #handleDragStart(event) {
    clearTimeout(this.constructor.longPressTimeout);
    const action = this._dragRight ? "dragRightStart" : "dragLeftStart";
    if ( !this.can(action, event) ) {
      this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
      this.cancel(event);
      return;
    }
    this.state = this.states.DRAG;
    if ( this.callback(action, event) === false ) {
      this.state = this.states.GRABBED;
      return this.#debug(action, event, this.handlerOutcomes.REFUSED);
    }
    return this.#debug(action, event, this.handlerOutcomes.ACCEPTED);
  }

  /* -------------------------------------------- */

  /**
   * Handle the continuation of a drag workflow, moving all controlled objects on the layer
   * @param {PIXI.FederatedEvent} event
   */
  #handleDragMove(event) {
    clearTimeout(this.constructor.longPressTimeout);
    const action = this._dragRight ? "dragRightMove" : "dragLeftMove";
    if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);
    const handled = this.callback(action, event);
    return this.#debug(action, event, handled ? this.handlerOutcomes.ACCEPTED : this.handlerOutcomes.REFUSED);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse up events which may optionally conclude a drag workflow
   * @param {PIXI.FederatedEvent} event
   */
  #handlePointerUp(event) {
    clearTimeout(this.constructor.longPressTimeout);
    // If this is a touch hover event, treat it as a drag
    if ( (this.state === this.states.HOVER) && (event.pointerType === "touch") ) {
      this.state = this.states.DRAG;
    }

    // Save prior state
    const priorState = this.state;

    // Update event data
    this.interactionData.destination = event.getLocalPosition(this.layer, this.interactionData.destination);

    if ( this.state >= this.states.DRAG ) {
      event.stopPropagation();
      if ( event.type.startsWith("right") && !this._dragRight ) return;
      if ( this.state === this.states.DRAG ) this.#handleDragDrop(event);
    }

    // Continue a multi-click drag workflow
    if ( event.defaultPrevented ) {
      this.state = priorState;
      return this.#debug("mouseUp", event, this.handlerOutcomes.SKIPPED);
    }

    // Handle the unclick event
    this.#handleUnclick(event);

    // Cancel the drag workflow
    this.#handleDragCancel(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle the conclusion of a drag workflow, placing all dragged objects back on the layer
   * @param {PIXI.FederatedEvent} event
   */
  #handleDragDrop(event) {
    const action = this._dragRight ? "dragRightDrop" : "dragLeftDrop";
    if ( !this.can(action, event) ) return this.#debug(action, event, this.handlerOutcomes.DISALLOWED);

    // Was the drag-drop event handled by the callback?
    this.state = this.states.DROP;
    if ( this.callback(action, event) === false ) {
      this.state = this.states.DRAG;
      return this.#debug(action, event, this.handlerOutcomes.REFUSED);
    }

    // Update the workflow state
    return this.#debug(action, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle the cancellation of a drag workflow, resetting back to the original state
   * @param {PIXI.FederatedEvent} event
   */
  #handleDragCancel(event) {
    this.cancel(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle the unclick event
   * @param {PIXI.FederatedEvent} event
   */
  #handleUnclick(event) {
    const action = event.button === 0 ? "unclickLeft" : "unclickRight";
    if ( !this.state.between(this.states.CLICKED, this.states.GRABBED) ) {
      return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
    }
    if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
    return this.#debug(action, event);
  }

  /* -------------------------------------------- */

  /**
   * A public method to handle directly an event into this manager, according to its type.
   * Note: drag events are not handled.
   * @param {PIXI.FederatedEvent} event
   * @returns {boolean} Has the event been processed?
   */
  handleEvent(event) {
    switch ( event.type ) {
      case "pointerover":
        this.#handlePointerOver(event);
        break;
      case "pointerout":
        this.#handlePointerOut(event);
        break;
      case "pointerup":
        this.#handlePointerUp(event);
        break;
      case "pointerdown":
        this.#handlePointerDown(event);
        break;
      default:
        return false;
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * A public method to cancel a current interaction workflow from this manager.
   * @param {PIXI.FederatedEvent} [event]     The event that initiates the cancellation
   */
  cancel(event) {
    const eventSystem = canvas.app.renderer.events;
    const rootBoundary = eventSystem.rootBoundary;
    const createEvent = !event;
    if ( createEvent ) {
      event = rootBoundary.createPointerEvent(eventSystem.pointer, "pointermove", this.target);
      event.defaultPrevented = false;
      event.path = null;
    }
    try {
      const action = this._dragRight ? "dragRightCancel" : "dragLeftCancel";
      const endState = this.state;
      if ( endState <= this.states.HOVER ) return this.#debug(action, event, this.handlerOutcomes.SKIPPED);

      // Dispatch a cancellation callback
      if ( endState >= this.states.DRAG ) {
        if ( this.callback(action, event) === false ) return this.#debug(action, event, this.handlerOutcomes.REFUSED);
      }

      // Continue a multi-click drag workflow if the default event was prevented in the callback
      if ( event.defaultPrevented ) {
        this.state = this.states.DRAG;
        return this.#debug(action, event, this.handlerOutcomes.SKIPPED);
      }

      // Reset the interaction data and state and deactivate drag events
      this.interactionData = {};
      this.state = this.states.HOVER;
      canvas.currentMouseManager = null;
      clearTimeout(this.constructor.longPressTimeout);
      this.#deactivateDragEvents();
      this.#debug(action, event);

      // Check hover state and hover out if necessary
      if ( !rootBoundary.trackingData(event.pointerId).overTargets?.includes(this.target) ) {
        this.#handlePointerOut(event);
      }
    } finally {
      if ( createEvent ) rootBoundary.freeEvent(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Display a debug message in the console (if mouse interaction debug is activated).
   * @param {string} action                                   Which action to display?
   * @param {Event|PIXI.FederatedEvent} event                 Which event to display?
   * @param {number} [outcome=this.handlerOutcomes.ACCEPTED]  The handler outcome.
   */
  #debug(action, event, outcome=this.handlerOutcomes.ACCEPTED) {
    if ( CONFIG.debug.mouseInteraction ) {
      const name = this.object.constructor.name;
      const targetName = event.target?.constructor.name;
      const {eventPhase, type, button} = event;
      const state = Object.keys(this.states)[this.state.toString()];
      let msg = `${name} | ${action} | state:${state} | target:${targetName} | phase:${eventPhase} | type:${type} | `
      + `btn:${button} | skipped:${outcome <= -2} | allowed:${outcome > -1} | handled:${outcome > 1}`;
      console.debug(msg);
    }
  }

  /* -------------------------------------------- */

  /**
   * Reset the mouse manager.
   * @param {object} [options]
   * @param {boolean} [options.interactionData=true]    Reset the interaction data?
   * @param {boolean} [options.state=true]              Reset the state?
   */
  reset({interactionData=true, state=true}={}) {
    if ( CONFIG.debug.mouseInteraction ) {
      console.debug(`${this.object.constructor.name} | Reset | interactionData:${interactionData} | state:${state}`);
    }
    if ( interactionData ) this.interactionData = {};
    if ( state ) this.state = MouseInteractionManager.INTERACTION_STATES.NONE;
  }

  /* -------------------------------------------- */

  /**
   * Assign the interaction data to the event.
   * @param {PIXI.FederatedEvent} event
   */
  #assignInteractionData(event) {
    this.interactionData.object = this.object;
    event.interactionData = this.interactionData;

    // Add deprecated event data references
    for ( const k of Object.keys(this.interactionData) ) {
      if ( event.hasOwnProperty(k) ) continue;
      /**
       * @deprecated since v11
       * @ignore
       */
      Object.defineProperty(event, k, {
        get() {
          const msg = `event.data.${k} is deprecated in favor of event.interactionData.${k}.`;
          foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
          return this.interactionData[k];
        },
        set(value) {
          const msg = `event.data.${k} is deprecated in favor of event.interactionData.${k}.`;
          foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
          this.interactionData[k] = value;
        }
      });
    }
  }
}

/**
 * @typedef {object} PingOptions
 * @property {number} [duration=900]   The duration of the animation in milliseconds.
 * @property {number} [size=128]       The size of the ping graphic.
 * @property {string} [color=#ff6400]  The color of the ping graphic.
 * @property {string} [name]           The name for the ping animation to pass to {@link CanvasAnimation.animate}.
 */

/**
 * A class to manage a user ping on the canvas.
 * @param {Point} origin            The canvas coordinates of the origin of the ping.
 * @param {PingOptions} [options]   Additional options to configure the ping animation.
 */
class Ping extends PIXI.Container {
  constructor(origin, options={}) {
    super();
    this.x = origin.x;
    this.y = origin.y;
    this.options = foundry.utils.mergeObject({duration: 900, size: 128, color: "#ff6400"}, options);
    this._color = Color.from(this.options.color);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  destroy(options={}) {
    options.children = true;
    super.destroy(options);
  }

  /* -------------------------------------------- */

  /**
   * Start the ping animation.
   * @returns {Promise<boolean>}  Returns true if the animation ran to completion, false otherwise.
   */
  async animate() {
    const completed = await CanvasAnimation.animate([], {
      context: this,
      name: this.options.name,
      duration: this.options.duration,
      ontick: this._animateFrame.bind(this)
    });
    this.destroy();
    return completed;
  }

  /* -------------------------------------------- */

  /**
   * On each tick, advance the animation.
   * @param {number} dt                      The number of ms that elapsed since the previous frame.
   * @param {CanvasAnimationData} animation  The animation state.
   * @protected
   */
  _animateFrame(dt, animation) {
    throw new Error("Subclasses of Ping must implement the _animateFrame method.");
  }
}

/**
 * @typedef {Object} RenderFlag
 * @property {string[]} propagate     Activating this flag also sets these flags to true
 * @property {string[]} reset         Activating this flag resets these flags to false
 * @property {object} [deprecated]    Is this flag deprecated? The deprecation options are passed to
 *                                    logCompatibilityWarning. The deprectation message is auto-generated
 *                                    unless message is passed with the options.
 *                                    By default the message is logged only once.
 */

/**
 * A data structure for tracking a set of boolean status flags.
 * This is a restricted set which can only accept flag values which are pre-defined.
 * @param {Record<string, RenderFlag>} flags  An object which defines the flags which are supported for tracking
 * @param {object} [config]           Optional configuration
 * @param {RenderFlagObject} [config.object]  The object which owns this RenderFlags instance
 * @param {number} [config.priority]          The ticker priority at which these render flags are handled
 */
class RenderFlags extends Set {
  constructor(flags={}, {object, priority=PIXI.UPDATE_PRIORITY.OBJECTS}={}) {
    super([]);
    for ( const cfg of Object.values(flags) ) {
      cfg.propagate ||= [];
      cfg.reset ||= [];
    }
    Object.defineProperties(this, {
      /**
       * The flags tracked by this data structure.
       * @type {Record<string, RenderFlag>}
       */
      flags: {value: Object.freeze(flags), enumerable: false, writable: false},

      /**
       * The RenderFlagObject instance which owns this set of RenderFlags
       * @type {RenderFlagObject}
       */
      object: {value: object, enumerable: false, writable: false},

      /**
       * The update priority when these render flags are applied.
       * Valid options are OBJECTS or PERCEPTION.
       * @type {string}
       */
      priority: {value: priority, enumerable: false, writable: false}
    });
  }

  /* -------------------------------------------- */

  /**
   * @inheritDoc
   * @returns {Record<string, boolean>}     The flags which were previously set that have been cleared.
   */
  clear() {

    // Record which flags were previously active
    const flags = {};
    for ( const flag of this ) {
      flags[flag] = true;
    }

    // Empty the set
    super.clear();

    // Remove the object from the pending queue
    if ( this.object ) canvas.pendingRenderFlags[this.priority].delete(this.object);
    return flags;
  }

  /* -------------------------------------------- */

  /**
   * Allow for handling one single flag at a time.
   * This function returns whether the flag needs to be handled and removes it from the pending set.
   * @param {string} flag
   * @returns {boolean}
   */
  handle(flag) {
    const active = this.has(flag);
    this.delete(flag);
    return active;
  }

  /* -------------------------------------------- */

  /**
   * Activate certain flags, also toggling propagation and reset behaviors
   * @param {Record<string, boolean>} changes
   */
  set(changes) {
    const seen = new Set();
    for ( const [flag, value] of Object.entries(changes) ) {
      this.#set(flag, value, seen);
    }
    if ( this.object ) canvas.pendingRenderFlags[this.priority].add(this.object);
  }

  /* -------------------------------------------- */

  /**
   * Recursively set a flag.
   * This method applies propagation or reset behaviors when flags are assigned.
   * @param {string} flag
   * @param {boolean} value
   * @param {Set<string>} seen
   */
  #set(flag, value, seen) {
    if ( seen.has(flag) || !value ) return;
    seen.add(flag);
    const cfg = this.flags[flag];
    if ( !cfg ) throw new Error(`"${flag}" is not defined as a supported RenderFlag option.`);
    if ( cfg.deprecated ) this.#logDreprecationWarning(flag);
    if ( !cfg.alias ) this.add(flag);
    for ( const r of cfg.reset ) this.delete(r);
    for ( const p of cfg.propagate ) this.#set(p, true, seen);
  }

  /* -------------------------------------------- */

  /**
   * Log the deprecation warning of the flag.
   * @param {string} flag
   */
  #logDreprecationWarning(flag) {
    const cfg = this.flags[flag];
    if ( !cfg.deprecated ) throw new Error(`The RenderFlag "${flag}" is not deprecated`);
    let {message, ...options} = cfg.deprecated;
    if ( !message ) {
      message = `The RenderFlag "${flag}"`;
      if ( this.object ) message += ` of ${this.object.constructor.name}`;
      message += " is deprecated";
      if ( cfg.propagate.length === 0 ) message += " without replacement.";
      else if ( cfg.propagate.length === 1 ) message += ` in favor of ${cfg.propagate[0]}.`;
      else message += `. Use ${cfg.propagate.slice(0, -1).join(", ")} and/or ${cfg.propagate.at(-1)} instead.`;
    }
    options.once ??= true;
    foundry.utils.logCompatibilityWarning(message, options);
  }
}

/* -------------------------------------------- */

/**
 * Add RenderFlags functionality to some other object.
 * This mixin standardizes the interface for such functionality.
 * @param {typeof PIXI.DisplayObject|typeof Object} Base  The base class being mixed. Normally a PIXI.DisplayObject
 * @returns {typeof RenderFlagObject}                     The mixed class definition
 */
function RenderFlagsMixin(Base) {
  return class RenderFlagObject extends Base {
    constructor(...args) {
      super(...args);
      this.renderFlags = new RenderFlags(this.constructor.RENDER_FLAGS, {
        object: this,
        priority: this.constructor.RENDER_FLAG_PRIORITY
      });
    }

    /**
     * Configure the render flags used for this class.
     * @type {Record<string, RenderFlag>}
     */
    static RENDER_FLAGS = {};

    /**
     * The ticker priority when RenderFlags of this class are handled.
     * Valid values are OBJECTS or PERCEPTION.
     * @type {string}
     */
    static RENDER_FLAG_PRIORITY = "OBJECTS";

    /**
     * Status flags which are applied at render-time to update the PlaceableObject.
     * If an object defines RenderFlags, it should at least include flags for "redraw" and "refresh".
     * @type {RenderFlags}
     */
    renderFlags;

    /**
     * Apply any current render flags, clearing the renderFlags set.
     * Subclasses should override this method to define behavior.
     */
    applyRenderFlags() {
      this.renderFlags.clear();
    }
  };
}

/* -------------------------------------------- */

class ResizeHandle extends PIXI.Graphics {
  constructor(offset, handlers={}) {
    super();
    this.offset = offset;
    this.handlers = handlers;
    this.lineStyle(4, 0x000000, 1.0).beginFill(0xFF9829, 1.0).drawCircle(0, 0, 10).endFill();
    this.cursor = "pointer";
  }

  /**
   * Track whether the handle is being actively used for a drag workflow
   * @type {boolean}
   */
  active = false;

  /* -------------------------------------------- */

  refresh(bounds) {
    this.position.set(bounds.x + (bounds.width * this.offset[0]), bounds.y + (bounds.height * this.offset[1]));
    this.hitArea = new PIXI.Rectangle(-16, -16, 32, 32); // Make the handle easier to grab
  }

  /* -------------------------------------------- */

  updateDimensions(current, origin, destination, {aspectRatio=null}={}) {

    // Identify the change in dimensions
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;

    // Determine the new width and the new height
    let width = Math.max(origin.width + dx, 24);
    let height = Math.max(origin.height + dy, 24);

    // Constrain the aspect ratio
    if ( aspectRatio ) {
      if ( width >= height ) width = height * aspectRatio;
      else height = width / aspectRatio;
    }

    // Adjust the final points
    return {
      x: current.x,
      y: current.y,
      width: width * Math.sign(current.width),
      height: height * Math.sign(current.height)
    };
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  activateListeners() {
    this.off("pointerover").off("pointerout").off("pointerdown")
      .on("pointerover", this._onHoverIn.bind(this))
      .on("pointerout", this._onHoverOut.bind(this))
      .on("pointerdown", this._onMouseDown.bind(this));
    this.eventMode = "static";
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-over event on a control handle
   * @param {PIXI.FederatedEvent} event   The mouseover event
   * @protected
   */
  _onHoverIn(event) {
    const handle = event.target;
    handle.scale.set(1.5, 1.5);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-out event on a control handle
   * @param {PIXI.FederatedEvent} event   The mouseout event
   * @protected
   */
  _onHoverOut(event) {
    const handle = event.target;
    handle.scale.set(1.0, 1.0);
  }

  /* -------------------------------------------- */

  /**
   * When we start a drag event - create a preview copy of the Tile for re-positioning
   * @param {PIXI.FederatedEvent} event   The mousedown event
   * @protected
   */
  _onMouseDown(event) {
    if ( this.handlers.canDrag && !this.handlers.canDrag() ) return;
    this.active = true;
  }
}

/**
 * A subclass of Set which manages the Token ids which the User has targeted.
 * @extends {Set}
 * @see User#targets
 */
class UserTargets extends Set {
  constructor(user) {
    super();
    if ( user.targets ) throw new Error(`User ${user.id} already has a targets set defined`);
    this.user = user;
  }

  /**
   * Return the Token IDs which are user targets
   * @type {string[]}
   */
  get ids() {
    return Array.from(this).map(t => t.id);
  }

  /** @override */
  add(token) {
    if ( this.has(token) ) return this;
    super.add(token);
    this.#hook(token, true);
    return this;
  }

  /** @override */
  clear() {
    const tokens = Array.from(this);
    super.clear();
    tokens.forEach(t => this.#hook(t, false));
  }

  /** @override */
  delete(token) {
    if ( !this.has(token) ) return false;
    super.delete(token);
    this.#hook(token, false);
    return true;
  }

  /**
   * Dispatch the targetToken hook whenever the user's target set changes.
   * @param {Token} token        The targeted Token
   * @param {boolean} targeted   Whether the Token has been targeted or untargeted
   */
  #hook(token, targeted) {
    Hooks.callAll("targetToken", this.user, token, targeted);
  }
}

/**
 * A special class of Polygon which implements a limited angle of emission for a Point Source.
 * The shape is defined by a point origin, radius, angle, and rotation.
 * The shape is further customized by a configurable density which informs the approximation.
 * An optional secondary externalRadius can be provided which adds supplementary visibility outside the primary angle.
 */
class LimitedAnglePolygon extends PIXI.Polygon {
  constructor(origin, {radius, angle=360, rotation=0, density, externalRadius=0} = {}) {
    super([]);

    /**
     * The origin point of the Polygon
     * @type {Point}
     */
    this.origin = origin;

    /**
     * The radius of the emitted cone.
     * @type {number}
     */
    this.radius = radius;

    /**
     * The angle of the Polygon in degrees.
     * @type {number}
     */
    this.angle = angle;

    /**
     * The direction of rotation at the center of the emitted angle in degrees.
     * @type {number}
     */
    this.rotation = rotation;

    /**
     * The density of rays which approximate the cone, defined as rays per PI.
     * @type {number}
     */
    this.density = density ?? PIXI.Circle.approximateVertexDensity(this.radius);

    /**
     * An optional "external radius" which is included in the polygon for the supplementary area outside the cone.
     * @type {number}
     */
    this.externalRadius = externalRadius;

    /**
     * The angle of the left (counter-clockwise) edge of the emitted cone in radians.
     * @type {number}
     */
    this.aMin = Math.normalizeRadians(Math.toRadians(this.rotation + 90 - (this.angle / 2)));

    /**
     * The angle of the right (clockwise) edge of the emitted cone in radians.
     * @type {number}
     */
    this.aMax = this.aMin + Math.toRadians(this.angle);

    // Generate polygon points
    this.#generatePoints();
  }

  /**
   * The bounding box of the circle defined by the externalRadius, if any
   * @type {PIXI.Rectangle}
   */
  externalBounds;

  /* -------------------------------------------- */

  /**
   * Generate the points of the LimitedAnglePolygon using the provided configuration parameters.
   */
  #generatePoints() {
    const {x, y} = this.origin;

    // Construct polygon points for the primary angle
    const primaryAngle = this.aMax - this.aMin;
    const nPrimary = Math.ceil((primaryAngle * this.density) / (2 * Math.PI));
    const dPrimary = primaryAngle / nPrimary;
    for ( let i=0; i<=nPrimary; i++ ) {
      const pad = Ray.fromAngle(x, y, this.aMin + (i * dPrimary), this.radius);
      this.points.push(pad.B.x, pad.B.y);
    }

    // Add secondary angle
    if ( this.externalRadius ) {
      const secondaryAngle = (2 * Math.PI) - primaryAngle;
      const nSecondary = Math.ceil((secondaryAngle * this.density) / (2 * Math.PI));
      const dSecondary = secondaryAngle / nSecondary;
      for ( let i=0; i<=nSecondary; i++ ) {
        const pad = Ray.fromAngle(x, y, this.aMax + (i * dSecondary), this.externalRadius);
        this.points.push(pad.B.x, pad.B.y);
      }
      this.externalBounds = (new PIXI.Circle(x, y, this.externalRadius)).getBounds();
    }

    // No secondary angle
    else {
      this.points.unshift(x, y);
      this.points.push(x, y);
    }
  }

  /* -------------------------------------------- */

  /**
   * Restrict the edges which should be included in a PointSourcePolygon based on this specialized shape.
   * We use two tests to jointly keep or reject edges.
   * 1. If this shape uses an externalRadius, keep edges which collide with the bounding box of that circle.
   * 2. Keep edges which are contained within or collide with one of the primary angle boundary rays.
   * @param {Point} a             The first edge vertex
   * @param {Point} b             The second edge vertex
   * @returns {boolean}           Should the edge be included in the PointSourcePolygon computation?
   * @internal
   */
  _includeEdge(a, b) {

    // 1. If this shape uses an externalRadius, keep edges which collide with the bounding box of that circle.
    if ( this.externalBounds?.lineSegmentIntersects(a, b, {inside: true}) ) return true;

    // 2. Keep edges which are contained within or collide with one of the primary angle boundary rays.
    const roundPoint = p => ({x: Math.round(p.x), y: Math.round(p.y)});
    const rMin = Ray.fromAngle(this.origin.x, this.origin.y, this.aMin, this.radius);
    roundPoint(rMin.B);
    const rMax = Ray.fromAngle(this.origin.x, this.origin.y, this.aMax, this.radius);
    roundPoint(rMax.B);

    // If either vertex is inside, keep the edge
    if ( LimitedAnglePolygon.pointBetweenRays(a, rMin, rMax, this.angle) ) return true;
    if ( LimitedAnglePolygon.pointBetweenRays(b, rMin, rMax, this.angle) ) return true;

    // If both vertices are outside, test whether the edge collides with one (either) of the limiting rays
    if ( foundry.utils.lineSegmentIntersects(rMin.A, rMin.B, a, b) ) return true;
    if ( foundry.utils.lineSegmentIntersects(rMax.A, rMax.B, a, b) ) return true;

    // Otherwise, the edge can be discarded
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Test whether a vertex lies between two boundary rays.
   * If the angle is greater than 180, test for points between rMax and rMin (inverse).
   * Otherwise, keep vertices that are between the rays directly.
   * @param {Point} point             The candidate point
   * @param {PolygonRay} rMin         The counter-clockwise bounding ray
   * @param {PolygonRay} rMax         The clockwise bounding ray
   * @param {number} angle            The angle being tested, in degrees
   * @returns {boolean}               Is the vertex between the two rays?
   */
  static pointBetweenRays(point, rMin, rMax, angle) {
    const ccw = foundry.utils.orient2dFast;
    if ( angle > 180 ) {
      const outside = (ccw(rMax.A, rMax.B, point) <= 0) && (ccw(rMin.A, rMin.B, point) >= 0);
      return !outside;
    }
    return (ccw(rMin.A, rMin.B, point) <= 0) && (ccw(rMax.A, rMax.B, point) >= 0);
  }
}

// noinspection TypeScriptUMDGlobal
/**
 * A helper class used to construct triangulated polygon meshes
 * Allow to add padding and a specific depth value.
 * @param {number[]|PIXI.Polygon} poly      Closed polygon to be processed and converted to a mesh
 *                                          (array of points or PIXI Polygon)
 * @param {object|{}} options               Various options : normalizing, offsetting, add depth, ...
 */
class PolygonMesher {
  constructor(poly, options = {}) {
    this.options = {...this.constructor._defaultOptions, ...options};
    const {normalize, x, y, radius, scale, offset} = this.options;

    // Creating the scaled values
    this.#scaled.sradius = radius * scale;
    this.#scaled.sx = x * scale;
    this.#scaled.sy = y * scale;
    this.#scaled.soffset = offset * scale;

    // Computing required number of pass (minimum 1)
    this.#nbPass = Math.ceil(Math.abs(offset) / 3);

    // Get points from poly param
    const points = poly instanceof PIXI.Polygon ? poly.points : poly;
    if ( !Array.isArray(points) ) {
      throw new Error("You must provide a PIXI.Polygon or an array of vertices to the PolygonMesher constructor");
    }

    // Correcting normalize option if necessary. We can't normalize with a radius of 0.
    if ( normalize && (radius === 0) ) this.options.normalize = false;
    // Creating the mesh vertices
    this.#computePolygonMesh(points);
  }

  /**
   * Default options values
   * @type {Record<string,boolean|number>}
   */
  static _defaultOptions = {
    offset: 0,          // The position value in pixels
    normalize: false,   // Should the vertices be normalized?
    x: 0,               // The x origin
    y: 0,               // The y origin
    radius: 0,          // The radius
    depthOuter: 0,      // The depth value on the outer polygon
    depthInner: 1,      // The depth value on the inner(s) polygon(s)
    scale: 10e8,        // Constant multiplier to avoid floating point imprecision with ClipperLib
    miterLimit: 7,      // Distance of the miter limit, when sharp angles are cut during offsetting.
    interleaved: false  // Should the vertex data be interleaved into one VBO?
  };

  /* -------------------------------------------- */

  /**
   * Polygon mesh vertices
   * @type {number[]}
   */
  vertices = [];

  /**
   * Polygon mesh indices
   * @type {number[]}
   */
  indices = [];

  /**
   * Contains options to apply during the meshing process
   * @type {Record<string,boolean|number>}
   */
  options = {};

  /**
   * Contains some options values scaled by the constant factor
   * @type {Record<string,number>}
   * @private
   */
  #scaled = {};

  /**
   * Polygon mesh geometry
   * @type {PIXI.Geometry}
   * @private
   */
  #geometry = null;

  /**
   * Contain the polygon tree node object, containing the main forms and its holes and sub-polygons
   * @type {{poly: number[], nPoly: number[], children: object[]}}
   * @private
   */
  #polygonNodeTree = null;

  /**
   * Contains the the number of offset passes required to compute the polygon
   * @type {number}
   * @private
   */
  #nbPass;

  /* -------------------------------------------- */
  /*  Polygon Mesher static helper methods        */
  /* -------------------------------------------- */

  /**
   * Convert a flat points array into a 2 dimensional ClipperLib path
   * @param {number[]|PIXI.Polygon} poly             PIXI.Polygon or points flat array.
   * @param {number} [dimension=2]                   Dimension.
   * @returns {number[]|undefined}                   The clipper lib path.
   */
  static getClipperPathFromPoints(poly, dimension = 2) {
    poly = poly instanceof PIXI.Polygon ? poly.points : poly;

    // If points is not an array or if its dimension is 1, 0 or negative, it can't be translated to a path.
    if ( !Array.isArray(poly) || dimension < 2 ) {
      throw new Error("You must provide valid coordinates to create a path.");
    }

    const path = new ClipperLib.Path();
    if ( poly.length <= 1 ) return path; // Returning an empty path if we have zero or one point.

    for ( let i = 0; i < poly.length; i += dimension ) {
      path.push(new ClipperLib.IntPoint(poly[i], poly[i + 1]));
    }
    return path;
  }

  /* -------------------------------------------- */
  /*  Polygon Mesher Methods                      */
  /* -------------------------------------------- */

  /**
   * Create the polygon mesh
   * @param {number[]} points
   * @private
   */
  #computePolygonMesh(points) {
    if ( !points || points.length < 6 ) return;
    this.#updateVertices(points);
    this.#updatePolygonNodeTree();
  }

  /* -------------------------------------------- */

  /**
   * Update vertices and add depth
   * @param {number[]} vertices
   * @private
   */
  #updateVertices(vertices) {
    const {offset, depthOuter, scale} = this.options;
    const z = (offset === 0 ? 1.0 : depthOuter);
    for ( let i = 0; i < vertices.length; i += 2 ) {
      const x = Math.round(vertices[i] * scale);
      const y = Math.round(vertices[i + 1] * scale);
      this.vertices.push(x, y, z);
    }
  }

  /* -------------------------------------------- */

  /**
   * Create the polygon by generating the edges and the interior of the polygon if an offset != 0,
   * and just activate a fast triangulation if offset = 0
   * @private
   */
  #updatePolygonNodeTree() {
    // Initializing the polygon node tree
    this.#polygonNodeTree = {poly: this.vertices, nPoly: this.#normalize(this.vertices), children: []};

    // Computing offset only if necessary
    if ( this.options.offset === 0 ) return this.#polygonNodeTree.fastTriangulation = true;

    // Creating the offsetter ClipperLib object, and adding our polygon path to it.
    const offsetter = new ClipperLib.ClipperOffset(this.options.miterLimit);
    // Launching the offset computation
    return this.#createOffsetPolygon(offsetter, this.#polygonNodeTree);
  }

  /* -------------------------------------------- */

  /**
   * Recursively create offset polygons in successive passes
   * @param {ClipperLib.ClipperOffset} offsetter    ClipperLib offsetter
   * @param {object} node                           A polygon node object to offset
   * @param {number} [pass=0]                       The pass number (initialized with 0 for the first call)
   */
  #createOffsetPolygon(offsetter, node, pass = 0) {
    // Time to stop recursion on this node branch?
    if ( pass >= this.#nbPass ) return;
    const path = PolygonMesher.getClipperPathFromPoints(node.poly, 3);                                   // Converting polygon points to ClipperLib path
    const passOffset = Math.round(this.#scaled.soffset / this.#nbPass);                                  // Mapping the offset for this path
    const depth = Math.mix(this.options.depthOuter, this.options.depthInner, (pass + 1) / this.#nbPass); // Computing depth according to the actual pass and maximum number of pass (linear interpolation)

    // Executing the offset
    const paths = new ClipperLib.Paths();
    offsetter.AddPath(path, ClipperLib.JoinType.jtMiter, ClipperLib.EndType.etClosedPolygon);
    offsetter.Execute(paths, passOffset);
    offsetter.Clear();

    // Verifying if we have pathes. If it's not the case, the area is too small to generate pathes with this offset.
    // It's time to stop recursion on this node branch.
    if ( !paths.length ) return;

    // Incrementing the number of pass to know when recursive offset should stop
    pass++;

    // Creating offsets for children
    for ( const path of paths ) {
      const flat = this.#flattenVertices(path, depth);
      const child = { poly: flat, nPoly: this.#normalize(flat), children: []};
      node.children.push(child);
      this.#createOffsetPolygon(offsetter, child, pass);
    }
  }

  /* -------------------------------------------- */

  /**
   * Flatten a ClipperLib path to array of numbers
   * @param {ClipperLib.IntPoint[]} path  path to convert
   * @param {number} depth                depth to add to the flattened vertices
   * @returns {number[]}                  flattened array of points
   * @private
   */
  #flattenVertices(path, depth) {
    const flattened = [];
    for ( const point of path ) {
      flattened.push(point.X, point.Y, depth);
    }
    return flattened;
  }

  /* -------------------------------------------- */

  /**
   * Normalize polygon coordinates and put result into nPoly property.
   * @param {number[]} poly       the poly to normalize
   * @returns {number[]}           the normalized poly array
   * @private
   */
  #normalize(poly) {
    if ( !this.options.normalize ) return [];
    // Compute the normalized vertex
    const {sx, sy, sradius} = this.#scaled;
    const nPoly = [];
    for ( let i = 0; i < poly.length; i+=3 ) {
      const x = (poly[i] - sx) / sradius;
      const y = (poly[i+1] - sy) / sradius;
      nPoly.push(x, y, poly[i+2]);
    }
    return nPoly;
  }

  /* -------------------------------------------- */

  /**
   * Execute the triangulation to create indices
   * @param {PIXI.Geometry} geometry    A geometry to update
   * @returns {PIXI.Geometry}           The resulting geometry
   */
  triangulate(geometry) {
    this.#geometry = geometry;
    // Can we draw at least one triangle (counting z now)? If not, update or create an empty geometry
    if ( this.vertices.length < 9 ) return this.#emptyGeometry();
    // Triangulate the mesh and create indices
    if ( this.#polygonNodeTree.fastTriangulation ) this.#triangulateFast();
    else this.#triangulateTree();
    // Update the geometry
    return this.#updateGeometry();
  }

  /* -------------------------------------------- */

  /**
   * Fast triangulation of the polygon node tree
   * @private
   */
  #triangulateFast() {
    this.indices = PIXI.utils.earcut(this.vertices, null, 3);
    if ( this.options.normalize ) {
      this.vertices = this.#polygonNodeTree.nPoly;
    }
  }

  /* -------------------------------------------- */

  /**
   * Recursive triangulation of the polygon node tree
   * @private
   */
  #triangulateTree() {
    this.vertices = [];
    this.indices = this.#triangulateNode(this.#polygonNodeTree);
  }

  /* -------------------------------------------- */

  /**
   * Triangulate a node and its children recursively to compose a mesh with multiple levels of depth
   * @param {object} node            The polygon node tree to triangulate
   * @param {number[]} [indices=[]]  An optional array to receive indices (used for recursivity)
   * @returns {number[]}              An array of indices, result of the triangulation
   */
  #triangulateNode(node, indices = []) {
    const {normalize} = this.options;
    const vert = [];
    const polyLength = node.poly.length / 3;
    const hasChildren = !!node.children.length;
    vert.push(...node.poly);

    // If the node is the outer hull (beginning polygon), it has a position of 0 into the vertices array.
    if ( !node.position ) {
      node.position = 0;
      this.vertices.push(...(normalize ? node.nPoly : node.poly));
    }
    // If the polygon has no children, it is an interior polygon triangulated in the fast way. Returning here.
    if ( !hasChildren ) {
      indices.push(...(PIXI.utils.earcut(vert, null, 3).map(v => v + node.position)));
      return indices;
    }

    let holePosition = polyLength;
    let holes = [];
    let holeGroupPosition = 0;
    for ( const nodeChild of node.children ) {
      holes.push(holePosition);
      nodeChild.position = (this.vertices.length / 3);
      if ( !holeGroupPosition ) holeGroupPosition = nodeChild.position; // The position of the holes as a contiguous group.
      holePosition += (nodeChild.poly.length / 3);
      vert.push(...nodeChild.poly);
      this.vertices.push(...(normalize ? nodeChild.nPoly : nodeChild.poly));
    }

    // We need to shift the result of the indices, to match indices as it is saved in the vertices.
    // We are using earcutEdges to enforce links between the outer and inner(s) polygons.
    const holeGroupShift = holeGroupPosition - polyLength;
    indices.push(...(earcut.earcutEdges(vert, holes).map(v => {
      if ( v < polyLength ) return v + node.position;
      else return v + holeGroupShift;
    })));

    // Triangulating children
    for ( const nodeChild of node.children ) {
      this.#triangulateNode(nodeChild, indices);
    }
    return indices;
  }

  /* -------------------------------------------- */

  /**
   * Updating or creating the PIXI.Geometry that will be used by the mesh
   * @private
   */
  #updateGeometry() {
    const {interleaved, normalize, scale} = this.options;

    // Unscale non normalized vertices
    if ( !normalize ) {
      for ( let i = 0; i < this.vertices.length; i+=3 ) {
        this.vertices[i] /= scale;
        this.vertices[i+1] /= scale;
      }
    }

    // If VBO shouldn't be interleaved, we create a separate array for vertices and depth
    let vertices; let depth;
    if ( !interleaved ) {
      vertices = [];
      depth = [];
      for ( let i = 0; i < this.vertices.length; i+=3 ) {
        vertices.push(this.vertices[i], this.vertices[i+1]);
        depth.push(this.vertices[i+2]);
      }
    }
    else vertices = this.vertices;

    if ( this.#geometry ) {
      const vertBuffer = this.#geometry.getBuffer("aVertexPosition");
      vertBuffer.update(new Float32Array(vertices));
      const indicesBuffer = this.#geometry.getIndex();
      indicesBuffer.update(new Uint16Array(this.indices));
      if ( !interleaved ) {
        const depthBuffer = this.#geometry.getBuffer("aDepthValue");
        depthBuffer.update(new Float32Array(depth));
      }
    }
    else this.#geometry = this.#createGeometry(vertices, depth);
    return this.#geometry;
  }

  /* -------------------------------------------- */

  /**
   * Empty the geometry, or if geometry is null, create an empty geometry.
   * @private
   */
  #emptyGeometry() {
    const {interleaved} = this.options;

    // Empty the current geometry if it exists
    if ( this.#geometry ) {
      const vertBuffer = this.#geometry.getBuffer("aVertexPosition");
      vertBuffer.update(new Float32Array([0, 0]));
      const indicesBuffer = this.#geometry.getIndex();
      indicesBuffer.update(new Uint16Array([0, 0]));
      if ( !interleaved ) {
        const depthBuffer = this.#geometry.getBuffer("aDepthValue");
        depthBuffer.update(new Float32Array([0]));
      }
    }
    // Create an empty geometry otherwise
    else if ( interleaved ) {
      // Interleaved version
      return new PIXI.Geometry().addAttribute("aVertexPosition", [0, 0, 0], 3).addIndex([0, 0]);
    }
    else {
      this.#geometry = new PIXI.Geometry().addAttribute("aVertexPosition", [0, 0], 2)
        .addAttribute("aTextureCoord", [0, 0, 0, 1, 1, 1, 1, 0], 2)
        .addAttribute("aDepthValue", [0], 1)
        .addIndex([0, 0]);
    }
    return this.#geometry;
  }

  /* -------------------------------------------- */

  /**
   * Create a new Geometry from provided buffers
   * @param {number[]} vertices                 provided vertices array (interleaved or not)
   * @param {number[]} [depth=undefined]        provided depth array
   * @param {number[]} [indices=this.indices]   provided indices array
   * @returns {PIXI.Geometry}                    the new PIXI.Geometry constructed from the provided buffers
   */
  #createGeometry(vertices, depth=undefined, indices=this.indices) {
    if ( this.options.interleaved ) {
      return new PIXI.Geometry().addAttribute("aVertexPosition", vertices, 3).addIndex(indices);
    }
    if ( !depth ) throw new Error("You must provide a separate depth buffer when the data is not interleaved.");
    return new PIXI.Geometry()
      .addAttribute("aVertexPosition", vertices, 2)
      .addAttribute("aTextureCoord", [0, 0, 1, 0, 1, 1, 0, 1], 2)
      .addAttribute("aDepthValue", depth, 1)
      .addIndex(indices);
  }
}

/**
 * An extension of the default PIXI.Text object which forces double resolution.
 * At default resolution Text often looks blurry or fuzzy.
 */
class PreciseText extends PIXI.Text {
  constructor(...args) {
    super(...args);
    this._autoResolution = false;
    this._resolution = 2;
  }

  /**
   * Prepare a TextStyle object which merges the canvas defaults with user-provided options
   * @param {object} [options={}]   Additional options merged with the default TextStyle
   * @param {number} [options.anchor]       A text anchor point from CONST.TEXT_ANCHOR_POINTS
   * @returns {PIXI.TextStyle}      The prepared TextStyle
   */
  static getTextStyle({anchor, ...options}={}) {
    const style = CONFIG.canvasTextStyle.clone();
    for ( let [k, v] of Object.entries(options) ) {
      if ( v !== undefined ) style[k] = v;
    }

    // Positioning
    if ( !("align" in options) ) {
      if ( anchor === CONST.TEXT_ANCHOR_POINTS.LEFT ) style.align = "right";
      else if ( anchor === CONST.TEXT_ANCHOR_POINTS.RIGHT ) style.align = "left";
    }

    // Adaptive Stroke
    if ( !("stroke" in options) ) {
      const fill = Color.from(style.fill);
      style.stroke = fill.hsv[2] > 0.6 ? 0x000000 : 0xFFFFFF;
    }
    return style;
  }
}

/**
 * @typedef {Object} RayIntersection
 * @property {number} x     The x-coordinate of intersection
 * @property {number} y     The y-coordinate of intersection
 * @property {number} t0    The proximity to the Ray origin, as a ratio of distance
 * @property {number} t1    The proximity to the Ray destination, as a ratio of distance
 */

/**
 * A ray for the purposes of computing sight and collision
 * Given points A[x,y] and B[x,y]
 *
 * Slope-Intercept form:
 * y = a + bx
 * y = A.y + ((B.y - A.Y) / (B.x - A.x))x
 *
 * Parametric form:
 * R(t) = (1-t)A + tB
 *
 * @param {Point} A      The origin of the Ray
 * @param {Point} B      The destination of the Ray
 */
class Ray {
  constructor(A, B) {

    /**
     * The origin point, {x, y}
     * @type {Point}
     */
    this.A = A;

    /**
     * The destination point, {x, y}
     * @type {Point}
     */
    this.B = B;

    /**
     * The origin y-coordinate
     * @type {number}
     */
    this.y0 = A.y;

    /**
     * The origin x-coordinate
     * @type {number}
     */
    this.x0 = A.x;

    /**
     * The horizontal distance of the ray, x1 - x0
     * @type {number}
     */
    this.dx = B.x - A.x;

    /**
     * The vertical distance of the ray, y1 - y0
     * @type {number}
     */
    this.dy = B.y - A.y;

    /**
     * The slope of the ray, dy over dx
     * @type {number}
     */
    this.slope = this.dy / this.dx;
  }

  /* -------------------------------------------- */
  /*  Attributes                                  */
  /* -------------------------------------------- */

  /**
   * The cached angle, computed lazily in Ray#angle
   * @type {number}
   * @private
   */
  _angle = undefined;

  /**
   * The cached distance, computed lazily in Ray#distance
   * @type {number}
   * @private
   */
  _distance = undefined;

  /* -------------------------------------------- */

  /**
   * The normalized angle of the ray in radians on the range (-PI, PI).
   * The angle is computed lazily (only if required) and cached.
   * @type {number}
   */
  get angle() {
    if ( this._angle === undefined ) this._angle = Math.atan2(this.dy, this.dx);
    return this._angle;
  }

  set angle(value) {
    this._angle = Number(value);
  }

  /* -------------------------------------------- */

  /**
   * A normalized bounding rectangle that encompasses the Ray
   * @type {PIXI.Rectangle}
   */
  get bounds() {
    return new PIXI.Rectangle(this.A.x, this.A.y, this.dx, this.dy).normalize();
  }

  /* -------------------------------------------- */

  /**
   * The distance (length) of the Ray in pixels.
   * The distance is computed lazily (only if required) and cached.
   * @type {number}
   */
  get distance() {
    if ( this._distance === undefined ) this._distance = Math.hypot(this.dx, this.dy);
    return this._distance;
  }
  set distance(value) {
    this._distance = Number(value);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * A factory method to construct a Ray from an origin point, an angle, and a distance
   * @param {number} x          The origin x-coordinate
   * @param {number} y          The origin y-coordinate
   * @param {number} radians    The ray angle in radians
   * @param {number} distance   The distance of the ray in pixels
   * @returns {Ray}             The constructed Ray instance
   */
  static fromAngle(x, y, radians, distance) {
    const dx = Math.cos(radians);
    const dy = Math.sin(radians);
    const ray = this.fromArrays([x, y], [x + (dx * distance), y + (dy * distance)]);
    ray._angle = Math.normalizeRadians(radians); // Store the angle, cheaper to compute here
    ray._distance = distance; // Store the distance, cheaper to compute here
    return ray;
  }

  /* -------------------------------------------- */

  /**
   * A factory method to construct a Ray from points in array format.
   * @param {number[]} A    The origin point [x,y]
   * @param {number[]} B    The destination point [x,y]
   * @returns {Ray}         The constructed Ray instance
   */
  static fromArrays(A, B) {
    return new this({x: A[0], y: A[1]}, {x: B[0], y: B[1]});
  }

  /* -------------------------------------------- */

  /**
   * Project the Array by some proportion of it's initial distance.
   * Return the coordinates of that point along the path.
   * @param {number} t    The distance along the Ray
   * @returns {Object}    The coordinates of the projected point
   */
  project(t) {
    return {
      x: this.A.x + (t * this.dx),
      y: this.A.y + (t * this.dy)
    };
  }

  /* -------------------------------------------- */

  /**
   * Create a Ray by projecting a certain distance towards a known point.
   * @param {Point} origin      The origin of the Ray
   * @param {Point} point       The point towards which to project
   * @param {number} distance   The distance of projection
   * @returns {Ray}
   */
  static towardsPoint(origin, point, distance) {
    const dx = point.x - origin.x;
    const dy = point.y - origin.y;
    const t = distance / Math.hypot(dx, dy);
    return new this(origin, {
      x: origin.x + (t * dx),
      y: origin.y + (t * dy)
    });
  }

  /* -------------------------------------------- */

  /**
   * Create a Ray by projecting a certain squared-distance towards a known point.
   * @param {Point} origin      The origin of the Ray
   * @param {Point} point       The point towards which to project
   * @param {number} distance2  The squared distance of projection
   * @returns {Ray}
   */
  static towardsPointSquared(origin, point, distance2) {
    const dx = point.x - origin.x;
    const dy = point.y - origin.y;
    const t = Math.sqrt(distance2 / (Math.pow(dx, 2) + Math.pow(dy, 2)));
    return new this(origin, {
      x: origin.x + (t * dx),
      y: origin.y + (t * dy)
    });
  }

  /* -------------------------------------------- */

  /**
   * Reverse the direction of the Ray, returning a second Ray
   * @returns {Ray}
   */
  reverse() {
    const r = new Ray(this.B, this.A);
    r._distance = this._distance;
    r._angle = Math.PI - this._angle;
    return r;
  }

  /* -------------------------------------------- */

  /**
   * Create a new ray which uses the same origin point, but a slightly offset angle and distance
   * @param {number} offset       An offset in radians which modifies the angle of the original Ray
   * @param {number} [distance]   A distance the new ray should project, otherwise uses the same distance.
   * @return {Ray}                A new Ray with an offset angle
   */
  shiftAngle(offset, distance) {
    return this.constructor.fromAngle(this.x0, this.y0, this.angle + offset, distance || this.distance);
  }

  /* -------------------------------------------- */

  /**
   * Find the point I[x,y] and distance t* on ray R(t) which intersects another ray
   * @see foundry.utils.lineLineIntersection
   */
  intersectSegment(coords) {
    return foundry.utils.lineSegmentIntersection(this.A, this.B, {x: coords[0], y: coords[1]}, {x: coords[2], y: coords[3]});
  }
}

/**
 * @typedef {"light"|"sight"|"sound"|"move"|"universal"} PointSourcePolygonType
 */

/**
 * @typedef {Object} PointSourcePolygonConfig
 * @property {PointSourcePolygonType} type  The type of polygon being computed
 * @property {number} [angle=360]   The angle of emission, if limited
 * @property {number} [density]     The desired density of padding rays, a number per PI
 * @property {number} [radius]      A limited radius of the resulting polygon
 * @property {number} [rotation]    The direction of facing, required if the angle is limited
 * @property {number} [wallDirectionMode] Customize how wall direction of one-way walls is applied
 * @property {boolean} [useThreshold=false] Compute the polygon with threshold wall constraints applied
 * @property {boolean} [includeDarkness=false] Include edges coming from darkness sources
 * @property {number} [priority]    Priority when it comes to ignore edges from darkness sources
 * @property {boolean} [debug]      Display debugging visualization and logging for the polygon
 * @property {PointSource} [source] The object (if any) that spawned this polygon.
 * @property {Array<PIXI.Rectangle|PIXI.Circle|PIXI.Polygon>} [boundaryShapes] Limiting polygon boundary shapes
 * @property {Readonly<boolean>} [useInnerBounds]   Does this polygon use the Scene inner or outer bounding rectangle
 * @property {Readonly<boolean>} [hasLimitedRadius] Does this polygon have a limited radius?
 * @property {Readonly<boolean>} [hasLimitedAngle]  Does this polygon have a limited angle?
 * @property {Readonly<PIXI.Rectangle>} [boundingBox] The computed bounding box for the polygon
 */

/**
 * An extension of the default PIXI.Polygon which is used to represent the line of sight for a point source.
 * @extends {PIXI.Polygon}
 */
class PointSourcePolygon extends PIXI.Polygon {

  /**
   * Customize how wall direction of one-way walls is applied
   * @enum {number}
   */
  static WALL_DIRECTION_MODES = Object.freeze({
    NORMAL: 0,
    REVERSED: 1,
    BOTH: 2
  });

  /**
   * The rectangular bounds of this polygon
   * @type {PIXI.Rectangle}
   */
  bounds = new PIXI.Rectangle(0, 0, 0, 0);

  /**
   * The origin point of the source polygon.
   * @type {Point}
   */
  origin;

  /**
   * The configuration of this polygon.
   * @type {PointSourcePolygonConfig}
   */
  config = {};

  /* -------------------------------------------- */

  /**
   * An indicator for whether this polygon is constrained by some boundary shape?
   * @type {boolean}
   */
  get isConstrained() {
    return this.config.boundaryShapes.length > 0;
  }

  /* -------------------------------------------- */

  /**
   * Benchmark the performance of polygon computation for this source
   * @param {number} iterations                 The number of test iterations to perform
   * @param {Point} origin                      The origin point to benchmark
   * @param {PointSourcePolygonConfig} config   The polygon configuration to benchmark
   */
  static benchmark(iterations, origin, config) {
    const f = () => this.create(foundry.utils.deepClone(origin), foundry.utils.deepClone(config));
    Object.defineProperty(f, "name", {value: `${this.name}.construct`, configurable: true});
    return foundry.utils.benchmark(f, iterations);
  }

  /* -------------------------------------------- */

  /**
   * Compute the polygon given a point origin and radius
   * @param {Point} origin                          The origin source point
   * @param {PointSourcePolygonConfig} [config={}]  Configuration options which customize the polygon computation
   * @returns {PointSourcePolygon}                  The computed polygon instance
   */
  static create(origin, config={}) {
    const poly = new this();
    poly.initialize(origin, config);
    poly.compute();
    return this.applyThresholdAttenuation(poly);
  }

  /* -------------------------------------------- */

  /**
   * Create a clone of this polygon.
   * This overrides the default PIXI.Polygon#clone behavior.
   * @override
   * @returns {PointSourcePolygon}    A cloned instance
   */
  clone() {
    const poly = new this.constructor([...this.points]);
    poly.config = foundry.utils.deepClone(this.config);
    poly.origin = {...this.origin};
    poly.bounds = this.bounds.clone();
    return poly;
  }

  /* -------------------------------------------- */
  /*  Polygon Computation                         */
  /* -------------------------------------------- */

  /**
   * Compute the polygon using the origin and configuration options.
   * @returns {PointSourcePolygon}    The computed polygon
   */
  compute() {
    let t0 = performance.now();
    const {x, y} = this.origin;
    const {width, height} = canvas.dimensions;
    const {angle, debug, radius} = this.config;

    if ( !(x >= 0 && x <= width && y >= 0 && y <= height) ) {
      console.warn("The polygon cannot be computed because its origin is out of the scene bounds.");
      this.points.length = 0;
      this.bounds = new PIXI.Rectangle(0, 0, 0, 0);
      return this;
    }

    // Skip zero-angle or zero-radius polygons
    if ( (radius === 0) || (angle === 0) ) {
      this.points.length = 0;
      this.bounds = new PIXI.Rectangle(0, 0, 0, 0);
      return this;
    }

    // Clear the polygon bounds
    this.bounds = undefined;

    // Delegate computation to the implementation
    this._compute();

    // Cache the new polygon bounds
    this.bounds = this.getBounds();

    // Debugging and performance metrics
    if ( debug ) {
      let t1 = performance.now();
      console.log(`Created ${this.constructor.name} in ${Math.round(t1 - t0)}ms`);
      this.visualize();
    }
    return this;
  }

  /**
   * Perform the implementation-specific computation
   * @protected
   */
  _compute() {
    throw new Error("Each subclass of PointSourcePolygon must define its own _compute method");
  }

  /* -------------------------------------------- */

  /**
   * Customize the provided configuration object for this polygon type.
   * @param {Point} origin                        The provided polygon origin
   * @param {PointSourcePolygonConfig} config     The provided configuration object
   */
  initialize(origin, config) {

    // Polygon origin
    const o = this.origin = {x: Math.round(origin.x), y: Math.round(origin.y)};

    // Configure radius
    const cfg = this.config = config;
    const maxR = canvas.dimensions.maxR;
    cfg.radius = Math.min(cfg.radius ?? maxR, maxR);
    cfg.hasLimitedRadius = (cfg.radius > 0) && (cfg.radius < maxR);
    cfg.density = cfg.density ?? PIXI.Circle.approximateVertexDensity(cfg.radius);

    // Configure angle
    cfg.angle = cfg.angle ?? 360;
    cfg.rotation = cfg.rotation ?? 0;
    cfg.hasLimitedAngle = cfg.angle !== 360;

    // Determine whether to use inner or outer bounds
    const sceneRect = canvas.dimensions.sceneRect;
    cfg.useInnerBounds ??= (cfg.type === "sight")
      && (o.x >= sceneRect.left && o.x <= sceneRect.right && o.y >= sceneRect.top && o.y <= sceneRect.bottom);

    // Customize wall direction
    cfg.wallDirectionMode ??= PointSourcePolygon.WALL_DIRECTION_MODES.NORMAL;

    // Configure threshold
    cfg.useThreshold ??= false;

    // Configure darkness inclusion
    cfg.includeDarkness ??= false;

    // Boundary Shapes
    cfg.boundaryShapes ||= [];
    if ( cfg.hasLimitedAngle ) this.#configureLimitedAngle();
    else if ( cfg.hasLimitedRadius ) this.#configureLimitedRadius();
    if ( CONFIG.debug.polygons ) cfg.debug = true;
  }

  /* -------------------------------------------- */

  /**
   * Configure a limited angle and rotation into a triangular polygon boundary shape.
   */
  #configureLimitedAngle() {
    this.config.boundaryShapes.push(new LimitedAnglePolygon(this.origin, this.config));
  }

  /* -------------------------------------------- */

  /**
   * Configure a provided limited radius as a circular polygon boundary shape.
   */
  #configureLimitedRadius() {
    this.config.boundaryShapes.push(new PIXI.Circle(this.origin.x, this.origin.y, this.config.radius));
  }

  /* -------------------------------------------- */

  /**
   * Apply a constraining boundary shape to an existing PointSourcePolygon.
   * Return a new instance of the polygon with the constraint applied.
   * The new instance is only a "shallow clone", as it shares references to component properties with the original.
   * @param {PIXI.Circle|PIXI.Rectangle|PIXI.Polygon} constraint      The constraining boundary shape
   * @param {object} [intersectionOptions]                            Options passed to the shape intersection method
   * @returns {PointSourcePolygon}                                    A new constrained polygon
   */
  applyConstraint(constraint, intersectionOptions={}) {

    // Enhance polygon configuration data using knowledge of the constraint
    const poly = this.clone();
    poly.config.boundaryShapes.push(constraint);
    if ( (constraint instanceof PIXI.Circle) && (constraint.x === this.origin.x) && (constraint.y === this.origin.y) ) {
      if ( poly.config.radius <= constraint.radius ) return poly;
      poly.config.radius = constraint.radius;
      poly.config.density = intersectionOptions.density ??= PIXI.Circle.approximateVertexDensity(constraint.radius);
      if ( constraint.radius === 0 ) {
        poly.points.length = 0;
        poly.bounds.x = poly.bounds.y = poly.bounds.width = poly.bounds.height = 0;
        return poly;
      }
    }
    if ( !poly.points.length ) return poly;
    // Apply the constraint and return the constrained polygon
    const c = constraint.intersectPolygon(poly, intersectionOptions);
    poly.points = c.points;
    poly.bounds = poly.getBounds();
    return poly;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  contains(x, y) {
    return this.bounds.contains(x, y) && super.contains(x, y);
  }

  /* -------------------------------------------- */
  /*  Polygon Boundary Constraints                */
  /* -------------------------------------------- */

  /**
   * Constrain polygon points by applying boundary shapes.
   * @protected
   */
  _constrainBoundaryShapes() {
    const {density, boundaryShapes} = this.config;
    if ( (this.points.length < 6) || !boundaryShapes.length ) return;
    let constrained = this;
    const intersectionOptions = {density, scalingFactor: 100};
    for ( const c of boundaryShapes ) {
      constrained = c.intersectPolygon(constrained, intersectionOptions);
    }
    this.points = constrained.points;
  }

  /* -------------------------------------------- */
  /*  Collision Testing                           */
  /* -------------------------------------------- */

  /**
   * Test whether a Ray between the origin and destination points would collide with a boundary of this Polygon.
   * A valid wall restriction type is compulsory and must be passed into the config options.
   * @param {Point} origin                          An origin point
   * @param {Point} destination                     A destination point
   * @param {PointSourcePolygonConfig} config       The configuration that defines a certain Polygon type
   * @param {"any"|"all"|"closest"} [config.mode]   The collision mode to test: "any", "all", or "closest"
   * @returns {boolean|PolygonVertex|PolygonVertex[]|null} The collision result depends on the mode of the test:
   *                                                * any: returns a boolean for whether any collision occurred
   *                                                * all: returns a sorted array of PolygonVertex instances
   *                                                * closest: returns a PolygonVertex instance or null
   */
  static testCollision(origin, destination, {mode="all", ...config}={}) {
    if ( !CONST.WALL_RESTRICTION_TYPES.includes(config.type) ) {
      throw new Error("A valid wall restriction type is required for testCollision.");
    }
    const poly = new this();
    const ray = new Ray(origin, destination);
    config.boundaryShapes ||= [];
    config.boundaryShapes.push(ray.bounds);
    poly.initialize(origin, config);
    return poly._testCollision(ray, mode);
  }

  /* -------------------------------------------- */

  /**
   * Determine the set of collisions which occurs for a Ray.
   * @param {Ray} ray                           The Ray to test
   * @param {string} mode                       The collision mode being tested
   * @returns {boolean|PolygonVertex|PolygonVertex[]|null} The collision test result
   * @protected
   * @abstract
   */
  _testCollision(ray, mode) {
    throw new Error(`The ${this.constructor.name} class must implement the _testCollision method`);
  }

  /* -------------------------------------------- */
  /*  Visualization and Debugging                 */
  /* -------------------------------------------- */

  /**
   * Visualize the polygon, displaying its computed area and applied boundary shapes.
   * @returns {PIXI.Graphics|undefined}     The rendered debugging shape
   */
  visualize() {
    if ( !this.points.length ) return;
    let dg = canvas.controls.debug;
    dg.clear();
    for ( const constraint of this.config.boundaryShapes ) {
      dg.lineStyle(2, 0xFFFFFF, 1.0).beginFill(0xAAFF00).drawShape(constraint).endFill();
    }
    dg.lineStyle(2, 0xFFFFFF, 1.0).beginFill(0xFFAA99, 0.25).drawShape(this).endFill();
    return dg;
  }

  /* -------------------------------------------- */

  /**
   * Determine if the shape is a complete circle.
   * The config object must have an angle and a radius properties.
   */
  isCompleteCircle() {
    const { radius, angle, density } = this.config;
    if ( radius === 0 ) return true;
    if ( angle < 360 || (this.points.length !== (density * 2)) ) return false;
    const shapeArea = Math.abs(this.signedArea());
    const circleArea = (0.5 * density * Math.sin(2 * Math.PI / density)) * (radius ** 2);
    return circleArea.almostEqual(shapeArea, 1e-5);
  }

  /* -------------------------------------------- */
  /*  Threshold Polygons                          */
  /* -------------------------------------------- */

  /**
   * Augment a PointSourcePolygon by adding additional coverage for shapes permitted by threshold walls.
   * @param {PointSourcePolygon} polygon        The computed polygon
   * @returns {PointSourcePolygon}              The augmented polygon
   */
  static applyThresholdAttenuation(polygon) {
    const config = polygon.config;
    if ( !config.useThreshold ) return polygon;

    // Identify threshold walls and confirm whether threshold augmentation is required
    const {nAttenuated, edges} = PointSourcePolygon.#getThresholdEdges(polygon.origin, config);
    if ( !nAttenuated ) return polygon;

    // Create attenuation shapes for all threshold walls
    const attenuationShapes = PointSourcePolygon.#createThresholdShapes(polygon, edges);
    if ( !attenuationShapes.length ) return polygon;

    // Compute a second polygon which does not enforce threshold walls
    const noThresholdPolygon = new this();
    noThresholdPolygon.initialize(polygon.origin, {...config, useThreshold: false});
    noThresholdPolygon.compute();

    // Combine the unrestricted polygon with the attenuation shapes
    const combined = PointSourcePolygon.#combineThresholdShapes(noThresholdPolygon, attenuationShapes);
    polygon.points = combined.points;
    polygon.bounds = polygon.getBounds();
    return polygon;
  }

  /* -------------------------------------------- */

  /**
   * Identify edges in the Scene which include an active threshold.
   * @param {Point} origin
   * @param {object} config
   * @returns {{edges: Edge[], nAttenuated: number}}
   */
  static #getThresholdEdges(origin, config) {
    let nAttenuated = 0;
    const edges = [];
    for ( const edge of canvas.edges.values() ) {
      if ( edge.applyThreshold(config.type, origin, config.externalRadius) ) {
        edges.push(edge);
        nAttenuated += edge.threshold.attenuation;
      }
    }
    return {edges, nAttenuated};
  }

  /* -------------------------------------------- */

  /**
   * @typedef {ClipperPoint[]} ClipperPoints
   */

  /**
   * For each threshold wall that this source passes through construct a shape representing the attenuated source.
   * The attenuated shape is a circle with a radius modified by origin proximity to the threshold wall.
   * Intersect the attenuated shape against the LOS with threshold walls considered.
   * The result is the LOS for the attenuated light source.
   * @param {PointSourcePolygon} thresholdPolygon   The computed polygon with thresholds applied
   * @param {Edge[]} edges                          The identified array of threshold walls
   * @returns {ClipperPoints[]}                     The resulting array of intersected threshold shapes
   */
  static #createThresholdShapes(thresholdPolygon, edges) {
    const cps = thresholdPolygon.toClipperPoints();
    const origin = thresholdPolygon.origin;
    const {radius, externalRadius, type} = thresholdPolygon.config;
    const shapes = [];

    // Iterate over threshold walls
    for ( const edge of edges ) {
      let thresholdShape;

      // Create attenuated shape
      if ( edge.threshold.attenuation ) {
        const r = PointSourcePolygon.#calculateThresholdAttenuation(edge, origin, radius, externalRadius, type);
        if ( !r.outside ) continue;
        thresholdShape = new PIXI.Circle(origin.x, origin.y, r.inside + r.outside);
      }

      // No attenuation, use the full circle
      else thresholdShape = new PIXI.Circle(origin.x, origin.y, radius);

      // Intersect each shape against the LOS
      const ix = thresholdShape.intersectClipper(cps, {convertSolution: false});
      if ( ix.length && ix[0].length > 2 ) shapes.push(ix[0]);
    }
    return shapes;
  }

  /* -------------------------------------------- */

  /**
   * Calculate the attenuation of the source as it passes through the threshold wall.
   * The distance of perception through the threshold wall depends on proximity of the source from the wall.
   * @param {Edge} edge         The Edge for which this threshold applies
   * @param {Point} origin      Origin point on the canvas for this source
   * @param {number} radius     Radius to use for this source, before considering attenuation
   * @param {number} externalRadius The external radius of the source
   * @param {string} type       Sense type for the source
   * @returns {{inside: number, outside: number}} The inside and outside portions of the radius
   */
  static #calculateThresholdAttenuation(edge, origin, radius, externalRadius, type) {
    const d = edge.threshold?.[type];
    if ( !d ) return { inside: radius, outside: radius };
    const proximity = edge[type] === CONST.WALL_SENSE_TYPES.PROXIMITY;

    // Find the closest point on the threshold wall to the source.
    // Calculate the proportion of the source radius that is "inside" and "outside" the threshold wall.
    const pt = foundry.utils.closestPointToSegment(origin, edge.a, edge.b);
    const inside = Math.hypot(pt.x - origin.x, pt.y - origin.y);
    const outside = radius - inside;
    if ( (outside < 0) || outside.almostEqual(0) ) return { inside, outside: 0 };

    // Attenuate the radius outside the threshold wall based on source proximity to the wall.
    const sourceDistance = proximity ? Math.max(inside - externalRadius, 0) : (inside + externalRadius);
    const percentDistance = sourceDistance / d;
    const pInv = proximity ? 1 - percentDistance : Math.min(1, percentDistance - 1);
    const a = (pInv / (2 * (1 - pInv))) * CONFIG.Wall.thresholdAttenuationMultiplier;
    return { inside, outside: Math.min(a * d, outside) };
  }

  /* -------------------------------------------- */

  /**
   * Union the attenuated shape-LOS intersections with the closed LOS.
   * The portion of the light sources "inside" the threshold walls are not modified from their default radius or shape.
   * Clipper can union everything at once. Use a positive fill to avoid checkerboard; fill any overlap.
   * @param {PointSourcePolygon} los    The LOS polygon with threshold walls inactive
   * @param {ClipperPoints[]} shapes    Attenuation shapes for threshold walls
   * @returns {PIXI.Polygon}            The combined LOS polygon with threshold shapes
   */
  static #combineThresholdShapes(los, shapes) {
    const c = new ClipperLib.Clipper();
    const combined = [];
    const cPaths = [los.toClipperPoints(), ...shapes];
    c.AddPaths(cPaths, ClipperLib.PolyType.ptSubject, true);
    const p = ClipperLib.PolyFillType.pftPositive;
    c.Execute(ClipperLib.ClipType.ctUnion, combined, p, p);
    return PIXI.Polygon.fromClipperPoints(combined.length ? combined[0] : []);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /** @ignore */
  get rays() {
    foundry.utils.logCompatibilityWarning("You are referencing PointSourcePolygon#rays which is no longer a required "
      + "property of that interface. If your subclass uses the rays property it should be explicitly defined by the "
      + "subclass which requires it.", {since: 11, until: 13});
    return this.#rays;
  }

  set rays(rays) {
    this.#rays = rays;
  }

  /** @deprecated since v11 */
  #rays = [];
}

/**
 * A type of ping that points to a specific location.
 * @param {Point} origin           The canvas coordinates of the origin of the ping.
 * @param {PingOptions} [options]  Additional options to configure the ping animation.
 * @extends Ping
 */
class ChevronPing extends Ping {
  constructor(origin, options={}) {
    super(origin, options);
    this._r = (this.options.size / 2) * .75;

    // The inner ring is 3/4s the size of the outer.
    this._rInner = this._r * .75;

    // The animation is split into three stages. First, the chevron fades in and moves downwards, then the rings fade
    // in, then everything fades out as the chevron moves back up.
    // Store the 1/4 time slice.
    this._t14 = this.options.duration * .25;

    // Store the 1/2 time slice.
    this._t12 = this.options.duration * .5;

    // Store the 3/4s time slice.
    this._t34 = this._t14 * 3;
  }

  /**
   * The path to the chevron texture.
   * @type {string}
   * @private
   */
  static _CHEVRON_PATH = "icons/pings/chevron.webp";

  /* -------------------------------------------- */

  /** @inheritdoc */
  async animate() {
    this.removeChildren();
    this.addChild(...this._createRings());
    this._chevron = await this._loadChevron();
    this.addChild(this._chevron);
    return super.animate();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _animateFrame(dt, animation) {
    const { time } = animation;
    if ( time < this._t14 ) {
      // Normalise t between 0 and 1.
      const t = time / this._t14;
      // Apply easing function.
      const dy = CanvasAnimation.easeOutCircle(t);
      this._chevron.y = this._y + (this._h2 * dy);
      this._chevron.alpha = time / this._t14;
    } else if ( time < this._t34 ) {
      const t = time - this._t14;
      const a = t / this._t12;
      this._drawRings(a);
    } else {
      const t = (time - this._t34) / this._t14;
      const a = 1 - t;
      const dy = CanvasAnimation.easeInCircle(t);
      this._chevron.y = this._y + ((1 - dy) * this._h2);
      this._chevron.alpha = a;
      this._drawRings(a);
    }
  }

  /* -------------------------------------------- */

  /**
   * Draw the outer and inner rings.
   * @param {number} a  The alpha.
   * @private
   */
  _drawRings(a) {
    this._outer.clear();
    this._inner.clear();
    this._outer.lineStyle(6, this._color, a).drawCircle(0, 0, this._r);
    this._inner.lineStyle(3, this._color, a).arc(0, 0, this._rInner, 0, Math.PI * 1.5);
  }

  /* -------------------------------------------- */

  /**
   * Load the chevron texture.
   * @returns {Promise<PIXI.Sprite>}
   * @private
   */
  async _loadChevron() {
    const texture = await TextureLoader.loader.loadTexture(ChevronPing._CHEVRON_PATH);
    const chevron = PIXI.Sprite.from(texture);
    chevron.tint = this._color;

    const w = this.options.size;
    const h = (texture.height / texture.width) * w;
    chevron.width = w;
    chevron.height = h;

    // The chevron begins the animation slightly above the pinged point.
    this._h2 = h / 2;
    chevron.x = -(w / 2);
    chevron.y = this._y = -h - this._h2;

    return chevron;
  }

  /* -------------------------------------------- */

  /**
   * Draw the two rings that are used as part of the ping animation.
   * @returns {PIXI.Graphics[]}
   * @private
   */
  _createRings() {
    this._outer = new PIXI.Graphics();
    this._inner = new PIXI.Graphics();
    return [this._outer, this._inner];
  }
}

/**
 * @typedef {PingOptions} PulsePingOptions
 * @property {number} [rings=3]         The number of rings used in the animation.
 * @property {string} [color2=#ffffff]  The alternate color that the rings begin at. Use white for a 'flashing' effect.
 */

/**
 * A type of ping that produces a pulsing animation.
 * @param {Point} origin                The canvas coordinates of the origin of the ping.
 * @param {PulsePingOptions} [options]  Additional options to configure the ping animation.
 * @extends Ping
 */
class PulsePing extends Ping {
  constructor(origin, {rings=3, color2="#ffffff", ...options}={}) {
    super(origin, {rings, color2, ...options});
    this._color2 = game.settings.get("core", "photosensitiveMode") ? this._color : Color.from(color2);

    // The radius is half the diameter.
    this._r = this.options.size / 2;

    // This is the radius that the rings initially begin at. It's set to 1/5th of the maximum radius.
    this._r0 = this._r / 5;

    this._computeTimeSlices();
  }

  /* -------------------------------------------- */

  /**
   * Initialize some time slice variables that will be used to control the animation.
   *
   * The animation for each ring can be separated into two consecutive stages.
   * Stage 1: Fade in a white ring with radius r0.
   * Stage 2: Expand radius outward. While the radius is expanding outward, we have two additional, consecutive
   * animations:
   *  Stage 2.1: Transition color from white to the configured color.
   *  Stage 2.2: Fade out.
   * 1/5th of the animation time is allocated to Stage 1. 4/5ths are allocated to Stage 2. Of those 4/5ths, 2/5ths
   * are allocated to Stage 2.1, and 2/5ths are allocated to Stage 2.2.
   * @private
   */
  _computeTimeSlices() {
    // We divide up the total duration of the animation into rings + 1 time slices. Ring animations are staggered by 1
    // slice, and last for a total of 2 slices each. This uses up the full duration and creates the ripple effect.
    this._timeSlice = this.options.duration / (this.options.rings + 1);
    this._timeSlice2 = this._timeSlice * 2;

    // Store the 1/5th time slice for Stage 1.
    this._timeSlice15 = this._timeSlice2 / 5;

    // Store the 2/5ths time slice for the subdivisions of Stage 2.
    this._timeSlice25 = this._timeSlice15 * 2;

    // Store the 4/5ths time slice for Stage 2.
    this._timeSlice45 = this._timeSlice25 * 2;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async animate() {
    // Draw rings.
    this.removeChildren();
    for ( let i = 0; i < this.options.rings; i++ ) {
      this.addChild(new PIXI.Graphics());
    }

    // Add a blur filter to soften the sharp edges of the shape.
    const f = new PIXI.BlurFilter(2);
    f.padding = this.options.size;
    this.filters = [f];

    return super.animate();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _animateFrame(dt, animation) {
    const { time } = animation;
    for ( let i = 0; i < this.options.rings; i++ ) {
      const ring = this.children[i];

      // Offset each ring by 1 time slice.
      const tMin = this._timeSlice * i;

      // Each ring gets 2 time slices to complete its full animation.
      const tMax = tMin + this._timeSlice2;

      // If it's not time for this ring to animate, do nothing.
      if ( (time < tMin) || (time >= tMax) ) continue;

      // Normalise our t.
      let t = time - tMin;

      ring.clear();
      if ( t < this._timeSlice15 ) {
        // Stage 1. Fade in a white ring of radius r0.
        const a = t / this._timeSlice15;
        this._drawShape(ring, this._color2, a, this._r0);
      } else {
        // Stage 2. Expand radius, transition color, and fade out. Re-normalize t for Stage 2.
        t -= this._timeSlice15;
        const dr = this._r / this._timeSlice45;
        const r = this._r0 + (t * dr);

        const c0 = this._color;
        const c1 = this._color2;
        const c = t <= this._timeSlice25 ? this._colorTransition(c0, c1, this._timeSlice25, t) : c0;

        const ta = Math.max(0, t - this._timeSlice25);
        const a = 1 - (ta / this._timeSlice25);
        this._drawShape(ring, c, a, r);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Transition linearly from one color to another.
   * @param {Color} from       The color to transition from.
   * @param {Color} to         The color to transition to.
   * @param {number} duration  The length of the transition in milliseconds.
   * @param {number} t         The current time along the duration.
   * @returns {number}         The incremental color between from and to.
   * @private
   */
  _colorTransition(from, to, duration, t) {
    const d = t / duration;
    const rgbFrom = from.rgb;
    const rgbTo = to.rgb;
    return Color.fromRGB(rgbFrom.map((c, i) => {
      const diff = rgbTo[i] - c;
      return c + (d * diff);
    }));
  }

  /* -------------------------------------------- */

  /**
   * Draw the shape for this ping.
   * @param {PIXI.Graphics} g  The graphics object to draw to.
   * @param {number} color     The color of the shape.
   * @param {number} alpha     The alpha of the shape.
   * @param {number} size      The size of the shape to draw.
   * @protected
   */
  _drawShape(g, color, alpha, size) {
    g.lineStyle({color, alpha, width: 6, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL});
    g.drawCircle(0, 0, size);
  }
}

/**
 * A type of ping that produces an arrow pointing in a given direction.
 * @property {PIXI.Point} origin            The canvas coordinates of the origin of the ping. This becomes the arrow's
 *                                          tip.
 * @property {PulsePingOptions} [options]   Additional options to configure the ping animation.
 * @property {number} [options.rotation=0]  The angle of the arrow in radians.
 * @extends PulsePing
 */
class ArrowPing extends PulsePing {
  constructor(origin, {rotation=0, ...options}={}) {
    super(origin, options);
    this.rotation = Math.normalizeRadians(rotation + (Math.PI * 1.5));
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _drawShape(g, color, alpha, size) {
    g.lineStyle({color, alpha, width: 6, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL});
    const half = size / 2;
    const x = -half;
    const y = -size;
    g.moveTo(x, y)
      .lineTo(0, 0)
      .lineTo(half, y)
      .lineTo(0, -half)
      .lineTo(x, y);
  }
}

/**
 * A type of ping that produces a pulse warning sign animation.
 * @param {PIXI.Point} origin           The canvas coordinates of the origin of the ping.
 * @param {PulsePingOptions} [options]  Additional options to configure the ping animation.
 * @extends PulsePing
 */
class AlertPing extends PulsePing {
  constructor(origin, {color="#ff0000", ...options}={}) {
    super(origin, {color, ...options});
    this._r = this.options.size;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _drawShape(g, color, alpha, size) {
    // Draw a chamfered triangle.
    g.lineStyle({color, alpha, width: 6, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL});
    const half = size / 2;
    const chamfer = size / 10;
    const chamfer2 = chamfer / 2;
    const x = -half;
    const y = -(size / 3);
    g.moveTo(x+chamfer, y)
      .lineTo(x+size-chamfer, y)
      .lineTo(x+size, y+chamfer)
      .lineTo(x+half+chamfer2, y+size-chamfer)
      .lineTo(x+half-chamfer2, y+size-chamfer)
      .lineTo(x, y+chamfer)
      .lineTo(x+chamfer, y);
  }
}

/**
 * An abstract pattern for primary layers of the game canvas to implement.
 * @category - Canvas
 * @abstract
 * @interface
 */
class CanvasLayer extends PIXI.Container {

  /**
   * Options for this layer instance.
   * @type {{name: string}}
   */
  options = this.constructor.layerOptions;

  // Default interactivity
  interactiveChildren = false;

  /* -------------------------------------------- */
  /*  Layer Attributes                            */
  /* -------------------------------------------- */

  /**
   * Customize behaviors of this CanvasLayer by modifying some behaviors at a class level.
   * @type {{name: string}}
   */
  static get layerOptions() {
    return {
      name: "",
      baseClass: CanvasLayer
    };
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the active instance of this canvas layer
   * @type {CanvasLayer}
   */
  static get instance() {
    return canvas[this.layerOptions.name];
  }

  /* -------------------------------------------- */

  /**
   * The canonical name of the CanvasLayer is the name of the constructor that is the immediate child of the
   * defined baseClass for the layer type.
   * @type {string}
   *
   * @example
   * canvas.lighting.name -> "LightingLayer"
   */
  get name() {
    const baseCls = this.constructor.layerOptions.baseClass;
    let cls = Object.getPrototypeOf(this.constructor);
    let name = this.constructor.name;
    while ( cls ) {
      if ( cls !== baseCls ) {
        name = cls.name;
        cls = Object.getPrototypeOf(cls);
      }
      else break;
    }
    return name;
  }

  /* -------------------------------------------- */

  /**
   * The name used by hooks to construct their hook string.
   * Note: You should override this getter if hookName should not return the class constructor name.
   * @type {string}
   */
  get hookName() {
    return this.name;
  }

  /* -------------------------------------------- */

  /**
   * An internal reference to a Promise in-progress to draw the CanvasLayer.
   * @type {Promise<CanvasLayer>}
   */
  #drawing = Promise.resolve(this);

  /* -------------------------------------------- */

  /**
   * Is the layer drawn?
   * @type {boolean}
   */
  #drawn = false;


  /* -------------------------------------------- */
  /*  Rendering
  /* -------------------------------------------- */

  /**
   * Draw the canvas layer, rendering its internal components and returning a Promise.
   * The Promise resolves to the drawn layer once its contents are successfully rendered.
   * @param {object} [options]      Options which configure how the layer is drawn
   * @returns {Promise<CanvasLayer>}
   */
  async draw(options={}) {
    return this.#drawing = this.#drawing.finally(async () => {
      console.log(`${vtt} | Drawing the ${this.constructor.name} canvas layer`);
      await this.tearDown();
      await this._draw(options);
      Hooks.callAll(`draw${this.hookName}`, this);
      this.#drawn = true;
    });
  }

  /**
   * The inner _draw method which must be defined by each CanvasLayer subclass.
   * @param {object} options      Options which configure how the layer is drawn
   * @abstract
   * @protected
   */
  async _draw(options) {
    throw new Error(`The ${this.constructor.name} subclass of CanvasLayer must define the _draw method`);
  }

  /* -------------------------------------------- */

  /**
   * Deconstruct data used in the current layer in preparation to re-draw the canvas
   * @param {object} [options]      Options which configure how the layer is deconstructed
   * @returns {Promise<CanvasLayer>}
   */
  async tearDown(options={}) {
    if ( !this.#drawn ) return this;
    MouseInteractionManager.emulateMoveEvent();
    this.#drawn = false;
    this.renderable = false;
    await this._tearDown(options);
    Hooks.callAll(`tearDown${this.hookName}`, this);
    this.renderable = true;
    MouseInteractionManager.emulateMoveEvent();
    return this;
  }

  /**
   * The inner _tearDown method which may be customized by each CanvasLayer subclass.
   * @param {object} options      Options which configure how the layer is deconstructed
   * @protected
   */
  async _tearDown(options) {
    this.removeChildren().forEach(c => c.destroy({children: true}));
  }
}

/**
 * A subclass of CanvasLayer which provides support for user interaction with its contained objects.
 * @category - Canvas
 */
class InteractionLayer extends CanvasLayer {

  /**
   * Is this layer currently active
   * @type {boolean}
   */
  get active() {
    return this.#active;
  }

  /** @ignore */
  #active = false;

  /** @override */
  eventMode = "passive";

  /**
   * Customize behaviors of this CanvasLayer by modifying some behaviors at a class level.
   * @type {{name: string, zIndex: number}}
   */
  static get layerOptions() {
    return Object.assign(super.layerOptions, {
      baseClass: InteractionLayer,
      zIndex: 0
    });
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Activate the InteractionLayer, deactivating other layers and marking this layer's children as interactive.
   * @param {object} [options]      Options which configure layer activation
   * @param {string} [options.tool]   A specific tool in the control palette to set as active
   * @returns {InteractionLayer}    The layer instance, now activated
   */
  activate({tool}={}) {

    // Set this layer as active
    const wasActive = this.#active;
    this.#active = true;

    // Deactivate other layers
    for ( const name of Object.keys(Canvas.layers) ) {
      const layer = canvas[name];
      if ( (layer !== this) && (layer instanceof InteractionLayer) ) layer.deactivate();
    }

    // Re-render Scene controls
    ui.controls?.initialize({layer: this.constructor.layerOptions.name, tool});

    if ( wasActive ) return this;

    // Reset the interaction manager
    canvas.mouseInteractionManager?.reset({state: false});

    // Assign interactivity for the active layer
    this.zIndex = this.getZIndex();
    this.eventMode = "static";
    this.interactiveChildren = true;

    // Call layer-specific activation procedures
    this._activate();
    Hooks.callAll(`activate${this.hookName}`, this);
    Hooks.callAll("activateCanvasLayer", this);
    return this;
  }

  /**
   * The inner _activate method which may be defined by each InteractionLayer subclass.
   * @protected
   */
  _activate() {}

  /* -------------------------------------------- */

  /**
   * Deactivate the InteractionLayer, removing interactivity from its children.
   * @returns {InteractionLayer}    The layer instance, now inactive
   */
  deactivate() {
    if ( !this.#active ) return this;
    canvas.highlightObjects(false);
    this.#active = false;
    this.eventMode = "passive";
    this.interactiveChildren = false;
    this.zIndex = this.getZIndex();
    this._deactivate();
    Hooks.callAll(`deactivate${this.hookName}`, this);
    return this;
  }

  /**
   * The inner _deactivate method which may be defined by each InteractionLayer subclass.
   * @protected
   */
  _deactivate() {}

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.hitArea = canvas.dimensions.rect;
    this.zIndex = this.getZIndex();
  }

  /* -------------------------------------------- */

  /**
   * Get the zIndex that should be used for ordering this layer vertically relative to others in the same Container.
   * @returns {number}
   */
  getZIndex() {
    return this.options.zIndex;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle left mouse-click events which originate from the Canvas stage.
   * @see {@link Canvas._onClickLeft}
   * @param {PIXI.FederatedEvent} event      The PIXI InteractionEvent which wraps a PointerEvent
   * @protected
   */
  _onClickLeft(event) {}

  /* -------------------------------------------- */

  /**
   * Handle double left-click events which originate from the Canvas stage.
   * @see {@link Canvas.#onClickLeft2}
   * @param {PIXI.FederatedEvent} event      The PIXI InteractionEvent which wraps a PointerEvent
   * @protected
   */
  _onClickLeft2(event) {}


  /* -------------------------------------------- */

  /**
   * Does the User have permission to left-click drag on the Canvas?
   * @param {User} user                    The User performing the action.
   * @param {PIXI.FederatedEvent} event    The event object.
   * @returns {boolean}
   * @protected
   */
  _canDragLeftStart(user, event) {
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Start a left-click drag workflow originating from the Canvas stage.
   * @see {@link Canvas.#onDragLeftStart}
   * @param {PIXI.FederatedEvent} event      The PIXI InteractionEvent which wraps a PointerEvent
   * @protected
   */
  _onDragLeftStart(event) {}

  /* -------------------------------------------- */

  /**
   * Continue a left-click drag workflow originating from the Canvas stage.
   * @see {@link Canvas.#onDragLeftMove}
   * @param {PIXI.FederatedEvent} event      The PIXI InteractionEvent which wraps a PointerEvent
   * @protected
   */
  _onDragLeftMove(event) {}

  /* -------------------------------------------- */

  /**
   * Conclude a left-click drag workflow originating from the Canvas stage.
   * @see {@link Canvas.#onDragLeftDrop}
   * @param {PIXI.FederatedEvent} event      The PIXI InteractionEvent which wraps a PointerEvent
   * @protected
   */
  _onDragLeftDrop(event) {}

  /* -------------------------------------------- */

  /**
   * Cancel a left-click drag workflow originating from the Canvas stage.
   * @see {@link Canvas.#onDragLeftDrop}
   * @param {PointerEvent} event              A right-click pointer event on the document.
   * @protected
   */
  _onDragLeftCancel(event) {}

  /* -------------------------------------------- */

  /**
   * Handle right mouse-click events which originate from the Canvas stage.
   * @see {@link Canvas._onClickRight}
   * @param {PIXI.FederatedEvent} event      The PIXI InteractionEvent which wraps a PointerEvent
   * @protected
   */
  _onClickRight(event) {}

  /* -------------------------------------------- */

  /**
   * Handle mouse-wheel events which occur for this active layer.
   * @see {@link MouseManager._onWheel}
   * @param {WheelEvent} event                The WheelEvent initiated on the document
   * @protected
   */
  _onMouseWheel(event) {}

  /* -------------------------------------------- */

  /**
   * Handle a DELETE keypress while this layer is active.
   * @see {@link ClientKeybindings._onDelete}
   * @param {KeyboardEvent} event             The delete key press event
   * @protected
   */
  async _onDeleteKey(event) {}
}

/* -------------------------------------------- */

/**
 * @typedef {Object} CanvasHistory
 * @property {string} type    The type of operation stored as history (create, update, delete)
 * @property {Object[]} data  The data corresponding to the action which may later be un-done
 */

/**
 * @typedef {Object} PlaceablesLayerOptions
 * @property {boolean} controllableObjects  Can placeable objects in this layer be controlled?
 * @property {boolean} rotatableObjects     Can placeable objects in this layer be rotated?
 * @property {boolean} confirmDeleteKey     Confirm placeable object deletion with a dialog?
 * @property {PlaceableObject} objectClass  The class used to represent an object on this layer.
 * @property {boolean} quadtree             Does this layer use a quadtree to track object positions?
 */

/**
 * A subclass of Canvas Layer which is specifically designed to contain multiple PlaceableObject instances,
 * each corresponding to an embedded Document.
 * @category - Canvas
 */
class PlaceablesLayer extends InteractionLayer {

  /**
   * Sort order for placeables belonging to this layer.
   * @type {number}
   */
  static SORT_ORDER = 0;

  /**
   * Placeable Layer Objects
   * @type {PIXI.Container|null}
   */
  objects = null;

  /**
   * Preview Object Placement
   */
  preview = null;

  /**
   * Keep track of history so that CTRL+Z can undo changes
   * @type {CanvasHistory[]}
   */
  history = [];

  /**
   * Keep track of an object copied with CTRL+C which can be pasted later
   * @type {PlaceableObject[]}
   */
  _copy = [];

  /**
   * A Quadtree which partitions and organizes Walls into quadrants for efficient target identification.
   * @type {Quadtree|null}
   */
  quadtree = this.options.quadtree ? new CanvasQuadtree() : null;

  /* -------------------------------------------- */
  /*  Attributes                                  */
  /* -------------------------------------------- */

  /**
   * Configuration options for the PlaceablesLayer.
   * @type {PlaceablesLayerOptions}
   */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      baseClass: PlaceablesLayer,
      controllableObjects: false,
      rotatableObjects: false,
      confirmDeleteKey: false,
      objectClass: CONFIG[this.documentName]?.objectClass,
      quadtree: true
    });
  }

  /* -------------------------------------------- */

  /**
   * A reference to the named Document type which is contained within this Canvas Layer.
   * @type {string}
   */
  static documentName;

  /**
   * Creation states affected to placeables during their construction.
   * @enum {number}
   */
  static CREATION_STATES = {
    NONE: 0,
    POTENTIAL: 1,
    CONFIRMED: 2,
    COMPLETED: 3
  };

  /* -------------------------------------------- */

  /**
   * Obtain a reference to the Collection of embedded Document instances within the currently viewed Scene
   * @type {Collection|null}
   */
  get documentCollection() {
    return canvas.scene?.getEmbeddedCollection(this.constructor.documentName) || null;
  }

  /* -------------------------------------------- */

  /**
   * Obtain a reference to the PlaceableObject class definition which represents the Document type in this layer.
   * @type {Function}
   */
  static get placeableClass() {
    return CONFIG[this.documentName].objectClass;
  }

  /* -------------------------------------------- */

  /**
   * If objects on this PlaceablesLayer have a HUD UI, provide a reference to its instance
   * @type {BasePlaceableHUD|null}
   */
  get hud() {
    return null;
  }

  /* -------------------------------------------- */

  /**
   * A convenience method for accessing the placeable object instances contained in this layer
   * @type {PlaceableObject[]}
   */
  get placeables() {
    if ( !this.objects ) return [];
    return this.objects.children;
  }

  /* -------------------------------------------- */

  /**
   * An Array of placeable objects in this layer which have the _controlled attribute
   * @returns {PlaceableObject[]}
   */
  get controlled() {
    return Array.from(this.#controlledObjects.values());
  }

  /* -------------------------------------------- */

  /**
   * Iterates over placeable objects that are eligible for control/select.
   * @yields A placeable object
   * @returns {Generator<PlaceableObject>}
   */
  *controllableObjects() {
    if ( !this.options.controllableObjects ) return;
    for ( const placeable of this.placeables ) {
      if ( placeable.visible ) yield placeable;
    }
  }

  /* -------------------------------------------- */

  /**
   * Track the set of PlaceableObjects on this layer which are currently controlled.
   * @type {Map<string,PlaceableObject>}
   */
  get controlledObjects() {
    return this.#controlledObjects;
  }

  /** @private */
  #controlledObjects = new Map();

  /* -------------------------------------------- */

  /**
   * Track the PlaceableObject on this layer which is currently hovered upon.
   * @type {PlaceableObject|null}
   */
  get hover() {
    return this.#hover;
  }

  set hover(object) {
    if ( object instanceof this.constructor.placeableClass ) this.#hover = object;
    else this.#hover = null;
  }

  #hover = null;

  /* -------------------------------------------- */

  /**
   * Track whether "highlight all objects" is currently active
   * @type {boolean}
   */
  highlightObjects = false;

  /* -------------------------------------------- */

  /**
   * Get the maximum sort value of all placeables.
   * @returns {number}    The maximum sort value (-Infinity if there are no objects)
   */
  getMaxSort() {
    let sort = -Infinity;
    const collection = this.documentCollection;
    if ( !collection?.documentClass.schema.has("sort") ) return sort;
    for ( const document of collection ) sort = Math.max(sort, document.sort);
    return sort;
  }

  /* -------------------------------------------- */

  /**
   * Send the controlled objects of this layer to the back or bring them to the front.
   * @param {boolean} front         Bring to front instead of send to back?
   * @returns {boolean}             Returns true if the layer has sortable object, and false otherwise
   * @internal
   */
  _sendToBackOrBringToFront(front) {
    const collection = this.documentCollection;
    const documentClass = collection?.documentClass;
    if ( !documentClass?.schema.has("sort") ) return false;
    if ( !this.controlled.length ) return true;

    // Determine to-be-updated objects and the minimum/maximum sort value of the other objects
    const toUpdate = [];
    let target = front ? -Infinity : Infinity;
    for ( const document of collection ) {
      if ( document.object?.controlled && !document.locked ) toUpdate.push(document);
      else target = (front ? Math.max : Math.min)(target, document.sort);
    }
    if ( !Number.isFinite(target) ) return true;
    target += (front ? 1 : -toUpdate.length);

    // Sort the to-be-updated objects by sort in ascending order
    toUpdate.sort((a, b) => a.sort - b.sort);

    // Update the to-be-updated objects
    const updates = toUpdate.map((document, i) => ({_id: document.id, sort: target + i}));
    canvas.scene.updateEmbeddedDocuments(documentClass.documentName, updates);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Snaps the given point to grid. The layer defines the snapping behavior.
   * @param {Point} point    The point that is to be snapped
   * @returns {Point}        The snapped point
   */
  getSnappedPoint(point) {
    const M = CONST.GRID_SNAPPING_MODES;
    const grid = canvas.grid;
    return grid.getSnappedPoint(point, {
      mode: grid.isHexagonal && !this.options.controllableObjects
        ? M.CENTER | M.VERTEX
        : M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
      resolution: 1
    });
  }

  /* -------------------------------------------- */
  /*  Rendering
  /* -------------------------------------------- */

  /**
   * Obtain an iterable of objects which should be added to this PlaceablesLayer
   * @returns {Document[]}
   */
  getDocuments() {
    return this.documentCollection || [];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);

    // Create objects container which can be sorted
    this.objects = this.addChild(new PIXI.Container());
    this.objects.sortableChildren = true;
    this.objects.visible = false;
    const cls = getDocumentClass(this.constructor.documentName);
    if ( (cls.schema.get("elevation") instanceof foundry.data.fields.NumberField)
      && (cls.schema.get("sort") instanceof foundry.data.fields.NumberField) ) {
      this.objects.sortChildren = PlaceablesLayer.#sortObjectsByElevationAndSort;
    }
    this.objects.on("childAdded", obj => {
      if ( !(obj instanceof this.constructor.placeableClass) ) {
        console.error(`An object of type ${obj.constructor.name} was added to ${this.constructor.name}#objects. `
          + `The object must be an instance of ${this.constructor.placeableClass.name}.`);
      }
      if ( obj instanceof PlaceableObject ) obj._updateQuadtree();
    });
    this.objects.on("childRemoved", obj => {
      if ( obj instanceof PlaceableObject ) obj._updateQuadtree();
    });

    // Create preview container which is always above objects
    this.preview = this.addChild(new PIXI.Container());

    // Create and draw objects
    const documents = this.getDocuments();
    const promises = documents.map(doc => {
      const obj = doc._object = this.createObject(doc);
      this.objects.addChild(obj);
      return obj.draw();
    });

    // Wait for all objects to draw
    await Promise.all(promises);
    this.objects.visible = this.active;
  }

  /* -------------------------------------------- */

  /**
   * Draw a single placeable object
   * @param {ClientDocument} document     The Document instance used to create the placeable object
   * @returns {PlaceableObject}
   */
  createObject(document) {
    return new this.constructor.placeableClass(document);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    this.history = [];
    if ( this.options.controllableObjects ) {
      this.controlledObjects.clear();
    }
    if ( this.hud ) this.hud.clear();
    if ( this.quadtree ) this.quadtree.clear();
    this.objects = null;
    return super._tearDown(options);
  }

  /**
   * The method to sort the objects elevation and sort before sorting by the z-index.
   * @type {Function}
   */
  static #sortObjectsByElevationAndSort = function() {
    for ( let i = 0; i < this.children.length; i++ ) {
      this.children[i]._lastSortedIndex = i;
    }
    this.children.sort((a, b) => (a.document.elevation - b.document.elevation)
      || (a.document.sort - b.document.sort)
      || (a.zIndex - b.zIndex)
      || (a._lastSortedIndex - b._lastSortedIndex)
    );
    this.sortDirty = false;
  };

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @override */
  _activate() {
    this.objects.visible = true;
    this.placeables.forEach(l => l.renderFlags.set({refreshState: true}));
  }

  /* -------------------------------------------- */

  /** @override */
  _deactivate() {
    this.objects.visible = false;
    this.releaseAll();
    this.placeables.forEach(l => l.renderFlags.set({refreshState: true}));
    this.clearPreviewContainer();
  }

  /* -------------------------------------------- */

  /**
   * Clear the contents of the preview container, restoring visibility of original (non-preview) objects.
   */
  clearPreviewContainer() {
    if ( !this.preview ) return;
    this.preview.removeChildren().forEach(c => {
      c._onDragEnd();
      c.destroy({children: true});
    });
  }

  /* -------------------------------------------- */

  /**
   * Get a PlaceableObject contained in this layer by its ID.
   * Returns undefined if the object doesn't exist or if the canvas is not rendering a Scene.
   * @param {string} objectId   The ID of the contained object to retrieve
   * @returns {PlaceableObject}  The object instance, or undefined
   */
  get(objectId) {
    return this.documentCollection?.get(objectId)?.object || undefined;
  }

  /* -------------------------------------------- */

  /**
   * Acquire control over all PlaceableObject instances which are visible and controllable within the layer.
   * @param {object} options      Options passed to the control method of each object
   * @returns {PlaceableObject[]}  An array of objects that were controlled
   */
  controlAll(options={}) {
    if ( !this.options.controllableObjects ) return [];
    options.releaseOthers = false;
    for ( const placeable of this.controllableObjects() ) {
      placeable.control(options);
    }
    return this.controlled;
  }

  /* -------------------------------------------- */

  /**
   * Release all controlled PlaceableObject instance from this layer.
   * @param {object} options   Options passed to the release method of each object
   * @returns {number}         The number of PlaceableObject instances which were released
   */
  releaseAll(options={}) {
    let released = 0;
    for ( let o of this.placeables ) {
      if ( !o.controlled ) continue;
      o.release(options);
      released++;
    }
    return released;
  }

  /* -------------------------------------------- */

  /**
   * Simultaneously rotate multiple PlaceableObjects using a provided angle or incremental.
   * This executes a single database operation using Scene#updateEmbeddedDocuments.
   * @param {object} options                Options which configure how multiple objects are rotated
   * @param {number} [options.angle]            A target angle of rotation (in degrees) where zero faces "south"
   * @param {number} [options.delta]            An incremental angle of rotation (in degrees)
   * @param {number} [options.snap]             Snap the resulting angle to a multiple of some increment (in degrees)
   * @param {Array} [options.ids]               An Array of object IDs to target for rotation
   * @param {boolean} [options.includeLocked=false] Rotate objects whose documents are locked?
   * @returns {Promise<PlaceableObject[]>}  An array of objects which were rotated
   * @throws                                An error if an explicitly provided id is not valid
   */
  async rotateMany({angle, delta, snap, ids, includeLocked=false}={}) {
    if ( (angle ?? delta ?? null) === null ) {
      throw new Error("Either a target angle or relative delta must be provided.");
    }

    // Rotation is not permitted
    if ( !this.options.rotatableObjects ) return [];
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return [];
    }

    // Identify the objects requested for rotation
    const objects = this._getMovableObjects(ids, includeLocked);
    if ( !objects.length ) return objects;

    // Conceal any active HUD
    this.hud?.clear();

    // Commit updates to the Scene
    const updateData = objects.map(o => ({
      _id: o.id,
      rotation: o._updateRotation({angle, delta, snap})
    }));
    await canvas.scene.updateEmbeddedDocuments(this.constructor.documentName, updateData);
    return objects;
  }

  /* -------------------------------------------- */

  /**
   * Simultaneously move multiple PlaceableObjects via keyboard movement offsets.
   * This executes a single database operation using Scene#updateEmbeddedDocuments.
   * @param {object} options                  Options which configure how multiple objects are moved
   * @param {-1|0|1} [options.dx=0]             Horizontal movement direction
   * @param {-1|0|1} [options.dy=0]             Vertical movement direction
   * @param {boolean} [options.rotate=false]    Rotate the placeable to direction instead of moving
   * @param {string[]} [options.ids]            An Array of object IDs to target for movement.
   *                                            The default is the IDs of controlled objects.
   * @param {boolean} [options.includeLocked=false] Move objects whose documents are locked?
   * @returns {Promise<PlaceableObject[]>}    An array of objects which were moved during the operation
   * @throws                                  An error if an explicitly provided id is not valid
   */
  async moveMany({dx=0, dy=0, rotate=false, ids, includeLocked=false}={}) {
    if ( ![-1, 0, 1].includes(dx) ) throw new Error("Invalid argument: dx must be -1, 0, or 1");
    if ( ![-1, 0, 1].includes(dy) ) throw new Error("Invalid argument: dy must be -1, 0, or 1");
    if ( !dx && !dy ) return [];
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return [];
    }

    // Identify the objects requested for movement
    const objects = this._getMovableObjects(ids, includeLocked);
    if ( !objects.length ) return objects;

    // Define rotation angles
    const rotationAngles = {
      square: [45, 135, 225, 315],
      hexR: [30, 150, 210, 330],
      hexQ: [60, 120, 240, 300]
    };

    // Determine the rotation angle
    let offsets = [dx, dy];
    let angle = 0;
    if ( rotate ) {
      let angles = rotationAngles.square;
      const gridType = canvas.grid.type;
      if ( gridType >= CONST.GRID_TYPES.HEXODDQ ) angles = rotationAngles.hexQ;
      else if ( gridType >= CONST.GRID_TYPES.HEXODDR ) angles = rotationAngles.hexR;
      if (offsets.equals([0, 1])) angle = 0;
      else if (offsets.equals([-1, 1])) angle = angles[0];
      else if (offsets.equals([-1, 0])) angle = 90;
      else if (offsets.equals([-1, -1])) angle = angles[1];
      else if (offsets.equals([0, -1])) angle = 180;
      else if (offsets.equals([1, -1])) angle = angles[2];
      else if (offsets.equals([1, 0])) angle = 270;
      else if (offsets.equals([1, 1])) angle = angles[3];
    }

    // Conceal any active HUD
    this.hud?.clear();

    // Commit updates to the Scene
    const updateData = objects.map(obj => {
      let update = {_id: obj.id};
      if ( rotate ) update.rotation = angle;
      else foundry.utils.mergeObject(update, obj._getShiftedPosition(...offsets));
      return update;
    });
    await canvas.scene.updateEmbeddedDocuments(this.constructor.documentName, updateData);
    return objects;
  }

  /* -------------------------------------------- */

  /**
   * An internal helper method to identify the array of PlaceableObjects which can be moved or rotated.
   * @param {string[]} ids            An explicit array of IDs requested.
   * @param {boolean} includeLocked   Include locked objects which would otherwise be ignored?
   * @returns {PlaceableObject[]}     An array of objects which can be moved or rotated
   * @throws                          An error if any explicitly requested ID is not valid
   * @internal
   */
  _getMovableObjects(ids, includeLocked) {
    if ( ids instanceof Array ) return ids.reduce((arr, id) => {
      const object = this.get(id);
      if ( !object ) throw new Error(`"${id} is not a valid ${this.constructor.documentName} in the current Scene`);
      if ( includeLocked || !object.document.locked ) arr.push(object);
      return arr;
    }, []);
    return this.controlled.filter(object => includeLocked || !object.document.locked);
  }

  /* -------------------------------------------- */

  /**
   * Undo a change to the objects in this layer
   * This method is typically activated using CTRL+Z while the layer is active
   * @returns {Promise<Document[]>}     An array of documents which were modified by the undo operation
   */
  async undoHistory() {
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return [];
    }
    const type = this.constructor.documentName;
    if ( !this.history.length ) {
      ui.notifications.info(game.i18n.format("CONTROLS.EmptyUndoHistory", {
        type: game.i18n.localize(getDocumentClass(type).metadata.label)}));
      return [];
    }
    let event = this.history.pop();

    // Undo creation with deletion
    if ( event.type === "create" ) {
      const ids = event.data.map(d => d._id);
      const deleted = await canvas.scene.deleteEmbeddedDocuments(type, ids, {isUndo: true});
      if ( deleted.length !== 1 ) ui.notifications.info(game.i18n.format("CONTROLS.UndoCreateObjects",
        {count: deleted.length, type: game.i18n.localize(getDocumentClass(type).metadata.label)}));
      return deleted;
    }

    // Undo updates with update
    else if ( event.type === "update" ) {
      return canvas.scene.updateEmbeddedDocuments(type, event.data, {isUndo: true});
    }

    // Undo deletion with creation
    else if ( event.type === "delete" ) {
      const created = await canvas.scene.createEmbeddedDocuments(type, event.data, {isUndo: true, keepId: true});
      if ( created.length !== 1 ) ui.notifications.info(game.i18n.format("CONTROLS.UndoDeleteObjects",
        {count: created.length, type: game.i18n.localize(getDocumentClass(type).metadata.label)}));
      return created;
    }
  }

  /* -------------------------------------------- */

  /**
   * A helper method to prompt for deletion of all PlaceableObject instances within the Scene
   * Renders a confirmation dialogue to confirm with the requester that all objects will be deleted
   * @returns {Promise<Document[]>}    An array of Document objects which were deleted by the operation
   */
  async deleteAll() {
    const type = this.constructor.documentName;
    if ( !game.user.isGM ) {
      throw new Error(`You do not have permission to delete ${type} objects from the Scene.`);
    }
    const typeLabel = game.i18n.localize(getDocumentClass(type).metadata.label);
    return Dialog.confirm({
      title: game.i18n.localize("CONTROLS.ClearAll"),
      content: `<p>${game.i18n.format("CONTROLS.ClearAllHint", {type: typeLabel})}</p>`,
      yes: async () => {
        const deleted = await canvas.scene.deleteEmbeddedDocuments(type, [], {deleteAll: true});
        ui.notifications.info(game.i18n.format("CONTROLS.DeletedObjects",
          {count: deleted.length, type: typeLabel}));
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Record a new CRUD event in the history log so that it can be undone later
   * @param {string} type   The event type (create, update, delete)
   * @param {Object[]} data   The object data
   */
  storeHistory(type, data) {
    if ( data.every(d => !("_id" in d)) ) throw new Error("The data entries must contain the _id key");
    if ( type === "update" ) data = data.filter(d => Object.keys(d).length > 1); // Filter entries without changes
    if ( data.length === 0 ) return; // Don't store empty history data
    if ( this.history.length >= 10 ) this.history.shift();
    this.history.push({type, data});
  }

  /* -------------------------------------------- */

  /**
   * Copy currently controlled PlaceableObjects to a temporary Array, ready to paste back into the scene later
   * @returns {PlaceableObject[]}             The Array of copied PlaceableObject instances
   */
  copyObjects() {
    if ( this.options.controllableObjects ) this._copy = [...this.controlled];
    else if ( this.hover ) this._copy = [this.hover];
    else this._copy = [];
    const typeLabel = game.i18n.localize(getDocumentClass(this.constructor.documentName).metadata.label);
    ui.notifications.info(game.i18n.format("CONTROLS.CopiedObjects", {
      count: this._copy.length,
      type: typeLabel
    }));
    return this._copy;
  }

  /* -------------------------------------------- */

  /**
   * Paste currently copied PlaceableObjects back to the layer by creating new copies
   * @param {Point} position      The destination position for the copied data.
   * @param {object} [options]    Options which modify the paste operation
   * @param {boolean} [options.hidden=false]    Paste data in a hidden state, if applicable. Default is false.
   * @param {boolean} [options.snap=true]       Snap the resulting objects to the grid. Default is true.
   * @returns {Promise<Document[]>} An Array of created Document instances
   */
  async pasteObjects(position, {hidden=false, snap=true}={}) {
    if ( !this._copy.length ) return [];

    // Get the center of all copies
    const center = {x: 0, y: 0};
    for ( const copy of this._copy ) {
      const c = copy.center;
      center.x += c.x;
      center.y += c.y;
    }
    center.x /= this._copy.length;
    center.y /= this._copy.length;

    // Offset of the destination position relative to the center
    const offset = {x: position.x - center.x, y: position.y - center.y};

    // Iterate over objects
    const toCreate = [];
    for ( const copy of this._copy ) {
      toCreate.push(this._pasteObject(copy, offset, {hidden, snap}));
    }

    /**
     * A hook event that fires when any PlaceableObject is pasted onto the
     * Scene. Substitute the PlaceableObject name in the hook event to target a
     * specific PlaceableObject type, for example "pasteToken".
     * @function pastePlaceableObject
     * @memberof hookEvents
     * @param {PlaceableObject[]} copied The PlaceableObjects that were copied
     * @param {object[]} createData      The new objects that will be added to the Scene
     */
    Hooks.call(`paste${this.constructor.documentName}`, this._copy, toCreate);

    // Create all objects
    const created = await canvas.scene.createEmbeddedDocuments(this.constructor.documentName, toCreate);
    ui.notifications.info(game.i18n.format("CONTROLS.PastedObjects", {count: created.length,
      type: game.i18n.localize(getDocumentClass(this.constructor.documentName).metadata.label)}));
    return created;
  }

  /* -------------------------------------------- */

  /**
   * Get the data of the copied object pasted at the position given by the offset.
   * Called by {@link PlaceablesLayer#pasteObjects} for each copied object.
   * @param {PlaceableObject} copy              The copied object that is pasted
   * @param {Point} offset                      The offset relative from the current position to the destination
   * @param {object} [options]                  Options of {@link PlaceablesLayer#pasteObjects}
   * @param {boolean} [options.hidden=false]    Paste in a hidden state, if applicable. Default is false.
   * @param {boolean} [options.snap=true]       Snap to the grid. Default is true.
   * @returns {object}                          The update data
   * @protected
   */
  _pasteObject(copy, offset, {hidden=false, snap=true}={}) {
    const {x, y} = copy.document;
    let position = {x: x + offset.x, y: y + offset.y};
    if ( snap ) position = this.getSnappedPoint(position);
    const d = canvas.dimensions;
    position.x = Math.clamp(position.x, 0, d.width - 1);
    position.y = Math.clamp(position.y, 0, d.height - 1);
    const data = copy.document.toObject();
    delete data._id;
    data.x = position.x;
    data.y = position.y;
    data.hidden ||= hidden;
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Select all PlaceableObject instances which fall within a coordinate rectangle.
   * @param {object} [options={}]
   * @param {number} [options.x]                     The top-left x-coordinate of the selection rectangle.
   * @param {number} [options.y]                     The top-left y-coordinate of the selection rectangle.
   * @param {number} [options.width]                 The width of the selection rectangle.
   * @param {number} [options.height]                The height of the selection rectangle.
   * @param {object} [options.releaseOptions={}]     Optional arguments provided to any called release() method.
   * @param {object} [options.controlOptions={}]     Optional arguments provided to any called control() method.
   * @param {object} [aoptions]                      Additional options to configure selection behaviour.
   * @param {boolean} [aoptions.releaseOthers=true]  Whether to release other selected objects.
   * @returns {boolean}       A boolean for whether the controlled set was changed in the operation.
   */
  selectObjects({x, y, width, height, releaseOptions={}, controlOptions={}}={}, {releaseOthers=true}={}) {
    if ( !this.options.controllableObjects ) return false;
    const oldSet = new Set(this.controlled);

    // Identify selected objects
    const newSet = new Set();
    const rectangle = new PIXI.Rectangle(x, y, width, height);
    for ( const placeable of this.controllableObjects() ) {
      if ( placeable._overlapsSelection(rectangle) ) newSet.add(placeable);
    }

    // Release objects that are no longer controlled
    const toRelease = oldSet.difference(newSet);
    if ( releaseOthers ) toRelease.forEach(placeable => placeable.release(releaseOptions));

    // Control objects that were not controlled before
    if ( foundry.utils.isEmpty(controlOptions) ) controlOptions.releaseOthers = false;
    const toControl = newSet.difference(oldSet);
    toControl.forEach(placeable => placeable.control(controlOptions));

    // Return a boolean for whether the control set was changed
    return (releaseOthers && (toRelease.size > 0)) || (toControl.size > 0);
  }

  /* -------------------------------------------- */

  /**
   * Update all objects in this layer with a provided transformation.
   * Conditionally filter to only apply to objects which match a certain condition.
   * @param {Function|object} transformation     An object of data or function to apply to all matched objects
   * @param {Function|null}  condition           A function which tests whether to target each object
   * @param {object} [options]                   Additional options passed to Document.update
   * @returns {Promise<Document[]>}              An array of updated data once the operation is complete
   */
  async updateAll(transformation, condition=null, options={}) {
    const hasTransformer = transformation instanceof Function;
    if ( !hasTransformer && (foundry.utils.getType(transformation) !== "Object") ) {
      throw new Error("You must provide a data object or transformation function");
    }
    const hasCondition = condition instanceof Function;
    const updates = this.placeables.reduce((arr, obj) => {
      if ( hasCondition && !condition(obj) ) return arr;
      const update = hasTransformer ? transformation(obj) : foundry.utils.deepClone(transformation);
      update._id = obj.id;
      arr.push(update);
      return arr;
    },[]);
    return canvas.scene.updateEmbeddedDocuments(this.constructor.documentName, updates, options);
  }

  /* -------------------------------------------- */

  /**
   * Get the world-transformed drop position.
   * @param {DragEvent} event
   * @param {object} [options]
   * @param {boolean} [options.center=true]  Return the coordinates of the center of the nearest grid element.
   * @returns {number[]|boolean}     Returns the transformed x, y coordinates, or false if the drag event was outside
   *                                 the canvas.
   * @protected
   */
  _canvasCoordinatesFromDrop(event, {center=true}={}) {
    let coords = canvas.canvasCoordinatesFromClient({x: event.clientX, y: event.clientY});
    if ( center ) coords = canvas.grid.getCenterPoint(coords);
    if ( canvas.dimensions.rect.contains(coords.x, coords.y) ) return [coords.x, coords.y];
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Create a preview of this layer's object type from a world document and show its sheet to be finalized.
   * @param {object} createData                     The data to create the object with.
   * @param {object} [options]                      Options which configure preview creation
   * @param {boolean} [options.renderSheet]           Render the preview object config sheet?
   * @param {number} [options.top]                    The offset-top position where the sheet should be rendered
   * @param {number} [options.left]                   The offset-left position where the sheet should be rendered
   * @returns {PlaceableObject}                     The created preview object
   * @internal
   */
  async _createPreview(createData, {renderSheet=true, top=0, left=0}={}) {
    const documentName = this.constructor.documentName;
    const cls = getDocumentClass(documentName);
    const document = new cls(createData, {parent: canvas.scene});
    if ( !document.canUserModify(game.user, "create") ) {
      return ui.notifications.warn(game.i18n.format("PERMISSION.WarningNoCreate", {document: documentName}));
    }

    const object = new CONFIG[documentName].objectClass(document);
    this.activate();
    this.preview.addChild(object);
    await object.draw();

    if ( renderSheet ) object.sheet.render(true, {top, left});
    return object;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _onClickLeft(event) {
    if ( !event.target.hasActiveHUD ) this.hud?.clear();
    if ( this.options.controllableObjects && game.settings.get("core", "leftClickRelease") && !this.hover ) {
      this.releaseAll();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _canDragLeftStart(user, event) {
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return false;
    }
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftStart(event) {
    this.clearPreviewContainer();
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftMove(event) {
    const preview = event.interactionData.preview;
    if ( !preview || preview._destroyed ) return;
    if ( preview.parent === null ) { // In theory this should never happen, but rarely does
      this.preview.addChild(preview);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftDrop(event) {
    const preview = event.interactionData.preview;
    if ( !preview || preview._destroyed ) return;
    event.interactionData.clearPreviewContainer = false;
    const cls = getDocumentClass(this.constructor.documentName);
    cls.create(preview.document.toObject(false), {parent: canvas.scene})
      .finally(() => this.clearPreviewContainer());
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftCancel(event) {
    if ( event.interactionData?.clearPreviewContainer !== false ) {
      this.clearPreviewContainer();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickRight(event) {
    if ( !event.target.hasActiveHUD ) this.hud?.clear();
  }

  /* -------------------------------------------- */

  /** @override */
  _onMouseWheel(event) {

    // Prevent wheel rotation during dragging
    if ( this.preview.children.length ) return;

    // Determine the incremental angle of rotation from event data
    const snap = event.shiftKey ? (canvas.grid.isHexagonal ? 30 : 45) : 15;
    const delta = snap * Math.sign(event.delta);
    return this.rotateMany({delta, snap});
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDeleteKey(event) {
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return;
    }

    // Identify objects which are candidates for deletion
    const objects = this.options.controllableObjects ? this.controlled : (this.hover ? [this.hover] : []);
    if ( !objects.length ) return;

    // Restrict to objects which can be deleted
    const ids = objects.reduce((ids, o) => {
      const isDragged = (o.interactionState === MouseInteractionManager.INTERACTION_STATES.DRAG);
      if ( isDragged || o.document.locked || !o.document.canUserModify(game.user, "delete") ) return ids;
      if ( this.hover === o ) this.hover = null;
      ids.push(o.id);
      return ids;
    }, []);
    if ( ids.length ) {
      const typeLabel = game.i18n.localize(getDocumentClass(this.constructor.documentName).metadata.label);
      if ( this.options.confirmDeleteKey ) {
        const confirmed = await foundry.applications.api.DialogV2.confirm({
          window: {
            title: game.i18n.format("DOCUMENT.Delete", {type: typeLabel})
          },
          content: `<p>${game.i18n.localize("AreYouSure")}</p>`,
          rejectClose: false
        });
        if ( !confirmed ) return;
      }
      const deleted = await canvas.scene.deleteEmbeddedDocuments(this.constructor.documentName, ids);
      if ( deleted.length !== 1) ui.notifications.info(game.i18n.format("CONTROLS.DeletedObjects", {
        count: deleted.length, type: typeLabel}));
    }
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get gridPrecision() {
    const msg = "PlaceablesLayer#gridPrecision is deprecated. Use PlaceablesLayer#getSnappedPoint "
      + "instead of GridLayer#getSnappedPosition and PlaceablesLayer#gridPrecision.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const grid = canvas.grid;
    if ( grid.type === CONST.GRID_TYPES.GRIDLESS ) return 0;           // No snapping for gridless
    if ( grid.type === CONST.GRID_TYPES.SQUARE ) return 2;             // Corners and centers
    return this.options.controllableObjects ? 2 : 5;                   // Corners or vertices
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  get _highlight() {
    const msg = "PlaceablesLayer#_highlight is deprecated. Use PlaceablesLayer#highlightObjects instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return this.highlightObjects;
  }

  /**
   * @deprecated since v11
   * @ignore
   */
  set _highlight(state) {
    const msg = "PlaceablesLayer#_highlight is deprecated. Use PlaceablesLayer#highlightObjects instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    this.highlightObjects = !!state;
  }
}

/**
 * An interface for defining particle-based weather effects
 * @param {PIXI.Container} parent     The parent container within which the effect is rendered
 * @param {object} [options]          Options passed to the getParticleEmitters method which can be used to customize
 *                                    values of the emitter configuration.
 * @interface
 */
class ParticleEffect extends FullCanvasObjectMixin(PIXI.Container) {
  constructor(options={}) {
    super();
    /**
     * The array of emitters which are active for this particle effect
     * @type {PIXI.particles.Emitter[]}
     */
    this.emitters = this.getParticleEmitters(options);
  }

  /* -------------------------------------------- */

  /**
   * Create an emitter instance which automatically updates using the shared PIXI.Ticker
   * @param {PIXI.particles.EmitterConfigV3} config   The emitter configuration
   * @returns {PIXI.particles.Emitter}                The created Emitter instance
   */
  createEmitter(config) {
    config.autoUpdate = true;
    config.emit = false;
    return new PIXI.particles.Emitter(this, config);
  }

  /* -------------------------------------------- */

  /**
   * Get the particle emitters which should be active for this particle effect.
   * This base class creates a single emitter using the explicitly provided configuration.
   * Subclasses can override this method for more advanced configurations.
   * @param {object} [options={}] Options provided to the ParticleEffect constructor which can be used to customize
   *                              configuration values for created emitters.
   * @returns {PIXI.particles.Emitter[]}
   */
  getParticleEmitters(options={}) {
    if ( foundry.utils.isEmpty(options) ) {
      throw new Error("The base ParticleEffect class may only be used with an explicitly provided configuration");
    }
    return [this.createEmitter(/** @type {PIXI.particles.EmitterConfigV3} */ options)];
  }

  /* -------------------------------------------- */

  /** @override */
  destroy(...args) {
    for ( const e of this.emitters ) e.destroy();
    this.emitters = [];
    super.destroy(...args);
  }

  /* -------------------------------------------- */

  /**
   * Begin animation for the configured emitters.
   */
  play() {
    for ( let e of this.emitters ) {
      e.emit = true;
    }
  }

  /* -------------------------------------------- */

  /**
   * Stop animation for the configured emitters.
   */
  stop() {
    for ( let e of this.emitters ) {
      e.emit = false;
    }
  }
}

/**
 * A full-screen weather effect which renders gently falling autumn leaves.
 * @extends {ParticleEffect}
 */
class AutumnLeavesWeatherEffect extends ParticleEffect {

  /** @inheritdoc */
  static label = "WEATHER.AutumnLeaves";

  /**
   * Configuration for the particle emitter for falling leaves
   * @type {PIXI.particles.EmitterConfigV3}
   */
  static LEAF_CONFIG = {
    lifetime: {min: 10, max: 10},
    behaviors: [
      {
        type: "alpha",
        config: {
          alpha: {
            list: [{time: 0, value: 0.9}, {time: 1, value: 0.5}]
          }
        }
      },
      {
        type: "moveSpeed",
        config: {
          speed: {
            list: [{time: 0, value: 20}, {time: 1, value: 60}]
          },
          minMult: 0.6
        }
      },
      {
        type: "scale",
        config: {
          scale: {
            list: [{time: 0, value: 0.2}, {time: 1, value: 0.4}]
          },
          minMult: 0.5
        }
      },
      {
        type: "rotation",
        config: {accel: 0, minSpeed: 100, maxSpeed: 200, minStart: 0, maxStart: 365}
      },
      {
        type: "textureRandom",
        config: {
          textures: Array.fromRange(6).map(n => `ui/particles/leaf${n + 1}.png`)
        }
      }
    ]
  };

  /* -------------------------------------------- */

  /** @inheritdoc */
  getParticleEmitters() {
    const d = canvas.dimensions;
    const maxParticles = (d.width / d.size) * (d.height / d.size) * 0.25;
    const config = foundry.utils.deepClone(this.constructor.LEAF_CONFIG);
    config.maxParticles = maxParticles;
    config.frequency = config.lifetime.min / maxParticles;
    config.behaviors.push({
      type: "spawnShape",
      config: {
        type: "rect",
        data: {x: d.sceneRect.x, y: d.sceneRect.y, w: d.sceneRect.width, h: d.sceneRect.height}
      }
    });
    return [this.createEmitter(config)];
  }
}

/**
 * A single Mouse Cursor
 * @type {PIXI.Container}
 */
class Cursor extends PIXI.Container {
  constructor(user) {
    super();
    this.target = {x: 0, y: 0};
    this.draw(user);
  }

  /**
   * To know if this cursor is animated
   * @type {boolean}
   */
  #animating;

  /* -------------------------------------------- */

  /**
   * Update visibility and animations
   * @param {User} user  The user
   */
  refreshVisibility(user) {
    const v = this.visible = !user.isSelf && user.hasPermission("SHOW_CURSOR");

    if ( v && !this.#animating ) {
      canvas.app.ticker.add(this._animate, this);
      this.#animating = true; // Set flag to true when animation is added
    } else if ( !v && this.#animating ) {
      canvas.app.ticker.remove(this._animate, this);
      this.#animating = false; // Set flag to false when animation is removed
    }
  }

  /* -------------------------------------------- */

  /**
   * Draw the user's cursor as a small dot with their user name attached as text
   */
  draw(user) {

    // Cursor dot
    const d = this.addChild(new PIXI.Graphics());
    d.beginFill(user.color, 0.35).lineStyle(1, 0x000000, 0.5).drawCircle(0, 0, 6);

    // Player name
    const style = CONFIG.canvasTextStyle.clone();
    style.fontSize = 14;
    let n = this.addChild(new PreciseText(user.name, style));
    n.x -= n.width / 2;
    n.y += 10;

    // Refresh
    this.refreshVisibility(user);
  }

  /* -------------------------------------------- */

  /**
   * Move an existing cursor to a new position smoothly along the animation loop
   */
  _animate() {
    const dy = this.target.y - this.y;
    const dx = this.target.x - this.x;
    if ( Math.abs( dx ) + Math.abs( dy ) < 10 ) return;
    this.x += dx / 10;
    this.y += dy / 10;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  destroy(options) {
    if ( this.#animating ) {
      canvas.app.ticker.remove(this._animate, this);
      this.#animating = false;
    }
    super.destroy(options);
  }
}

/**
 * An icon representing a Door Control
 * @extends {PIXI.Container}
 */
class DoorControl extends PIXI.Container {
  constructor(wall) {
    super();
    this.wall = wall;
    this.visible = false;  // Door controls are not visible by default
  }

  /* -------------------------------------------- */

  /**
   * The center of the wall which contains the door.
   * @type {PIXI.Point}
   */
  get center() {
    return this.wall.center;
  }

  /* -------------------------------------------- */

  /**
   * Draw the DoorControl icon, displaying its icon texture and border
   * @returns {Promise<DoorControl>}
   */
  async draw() {

    // Background
    this.bg = this.bg || this.addChild(new PIXI.Graphics());
    this.bg.clear().beginFill(0x000000, 1.0).drawRoundedRect(-2, -2, 44, 44, 5).endFill();
    this.bg.alpha = 0;

    // Control Icon
    this.icon = this.icon || this.addChild(new PIXI.Sprite());
    this.icon.width = this.icon.height = 40;
    this.icon.alpha = 0.6;
    this.icon.texture = this._getTexture();

    // Border
    this.border = this.border || this.addChild(new PIXI.Graphics());
    this.border.clear().lineStyle(1, 0xFF5500, 0.8).drawRoundedRect(-2, -2, 44, 44, 5).endFill();
    this.border.visible = false;

    // Add control interactivity
    this.eventMode = "static";
    this.interactiveChildren = false;
    this.hitArea = new PIXI.Rectangle(-2, -2, 44, 44);
    this.cursor = "pointer";

    // Set position
    this.reposition();
    this.alpha = 1.0;

    // Activate listeners
    this.removeAllListeners();
    this.on("pointerover", this._onMouseOver).on("pointerout", this._onMouseOut)
      .on("pointerdown", this._onMouseDown).on("rightdown", this._onRightDown);
    return this;
  }


  /* -------------------------------------------- */

  /**
   * Get the icon texture to use for the Door Control icon based on the door state
   * @returns {PIXI.Texture}
   */
  _getTexture() {

    // Determine displayed door state
    const ds = CONST.WALL_DOOR_STATES;
    let s = this.wall.document.ds;
    if ( !game.user.isGM && (s === ds.LOCKED) ) s = ds.CLOSED;

    // Determine texture path
    const icons = CONFIG.controlIcons;
    let path = {
      [ds.LOCKED]: icons.doorLocked,
      [ds.CLOSED]: icons.doorClosed,
      [ds.OPEN]: icons.doorOpen
    }[s] || icons.doorClosed;
    if ( (s === ds.CLOSED) && (this.wall.document.door === CONST.WALL_DOOR_TYPES.SECRET) ) path = icons.doorSecret;

    // Obtain the icon texture
    return getTexture(path);
  }

  /* -------------------------------------------- */

  reposition() {
    let pos = this.wall.midpoint.map(p => p - 20);
    this.position.set(...pos);
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the DoorControl is visible to the calling user's perspective.
   * The control is always visible if the user is a GM and no Tokens are controlled.
   * @see {CanvasVisibility#testVisibility}
   * @type {boolean}
   */
  get isVisible() {
    if ( !canvas.visibility.tokenVision ) return true;

    // Hide secret doors from players
    const w = this.wall;
    if ( (w.document.door === CONST.WALL_DOOR_TYPES.SECRET) && !game.user.isGM ) return false;

    // Test two points which are perpendicular to the door midpoint
    const ray = this.wall.toRay();
    const [x, y] = w.midpoint;
    const [dx, dy] = [-ray.dy, ray.dx];
    const t = 3 / (Math.abs(dx) + Math.abs(dy)); // Approximate with Manhattan distance for speed
    const points = [
      {x: x + (t * dx), y: y + (t * dy)},
      {x: x - (t * dx), y: y - (t * dy)}
    ];

    // Test each point for visibility
    return points.some(p => {
      return canvas.visibility.testVisibility(p, {object: this, tolerance: 0});
    });
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle mouse over events on a door control icon.
   * @param {PIXI.FederatedEvent} event      The originating interaction event
   * @protected
   */
  _onMouseOver(event) {
    event.stopPropagation();
    const canControl = game.user.can("WALL_DOORS");
    const blockPaused = game.paused && !game.user.isGM;
    if ( !canControl || blockPaused ) return false;
    this.border.visible = true;
    this.icon.alpha = 1.0;
    this.bg.alpha = 0.25;
    canvas.walls.hover = this.wall;
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse out events on a door control icon.
   * @param {PIXI.FederatedEvent} event      The originating interaction event
   * @protected
   */
  _onMouseOut(event) {
    event.stopPropagation();
    if ( game.paused && !game.user.isGM ) return false;
    this.border.visible = false;
    this.icon.alpha = 0.6;
    this.bg.alpha = 0;
    canvas.walls.hover = null;
  }

  /* -------------------------------------------- */

  /**
   * Handle left mouse down events on a door control icon.
   * This should only toggle between the OPEN and CLOSED states.
   * @param {PIXI.FederatedEvent} event      The originating interaction event
   * @protected
   */
  _onMouseDown(event) {
    if ( event.button !== 0 ) return; // Only support standard left-click
    event.stopPropagation();
    const { ds } = this.wall.document;
    const states = CONST.WALL_DOOR_STATES;

    // Determine whether the player can control the door at this time
    if ( !game.user.can("WALL_DOORS") ) return false;
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return false;
    }

    const sound = !(game.user.isGM && game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT));

    // Play an audio cue for testing locked doors, only for the current client
    if ( ds === states.LOCKED ) {
      if ( sound ) this.wall._playDoorSound("test");
      return false;
    }

    // Toggle between OPEN and CLOSED states
    return this.wall.document.update({ds: ds === states.CLOSED ? states.OPEN : states.CLOSED}, {sound});
  }

  /* -------------------------------------------- */

  /**
   * Handle right mouse down events on a door control icon.
   * This should toggle whether the door is LOCKED or CLOSED.
   * @param {PIXI.FederatedEvent} event      The originating interaction event
   * @protected
   */
  _onRightDown(event) {
    event.stopPropagation();
    if ( !game.user.isGM ) return;
    let state = this.wall.document.ds;
    const states = CONST.WALL_DOOR_STATES;
    if ( state === states.OPEN ) return;
    state = state === states.LOCKED ? states.CLOSED : states.LOCKED;
    const sound = !(game.user.isGM && game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT));
    return this.wall.document.update({ds: state}, {sound});
  }
}


/**
 * A CanvasLayer for displaying UI controls which are overlayed on top of other layers.
 *
 * We track three types of events:
 * 1) Cursor movement
 * 2) Ruler measurement
 * 3) Map pings
 */
class ControlsLayer extends InteractionLayer {
  constructor() {
    super();

    // Always interactive even if disabled for doors controls
    this.interactiveChildren = true;

    /**
     * A container of DoorControl instances
     * @type {PIXI.Container}
     */
    this.doors = this.addChild(new PIXI.Container());

    /**
     * A container of cursor interaction elements.
     * Contains cursors, rulers, interaction rectangles, and pings
     * @type {PIXI.Container}
     */
    this.cursors = this.addChild(new PIXI.Container());
    this.cursors.eventMode = "none";
    this.cursors.mask = canvas.masks.canvas;

    /**
     * Ruler tools, one per connected user
     * @type {PIXI.Container}
     */
    this.rulers = this.addChild(new PIXI.Container());
    this.rulers.eventMode = "none";

    /**
     * A graphics instance used for drawing debugging visualization
     * @type {PIXI.Graphics}
     */
    this.debug = this.addChild(new PIXI.Graphics());
    this.debug.eventMode = "none";
  }

  /**
   * The Canvas selection rectangle
   * @type {PIXI.Graphics}
   */
  select;

  /**
   * A mapping of user IDs to Cursor instances for quick access
   * @type {Record<string, Cursor>}
   */
  _cursors = {};

  /**
   * A mapping of user IDs to Ruler instances for quick access
   * @type {Record<string, Ruler>}
   * @private
   */
  _rulers = {};

  /**
   * The positions of any offscreen pings we are tracking.
   * @type {Record<string, Point>}
   * @private
   */
  _offscreenPings = {};

  /* -------------------------------------------- */

  /** @override */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "controls",
      zIndex: 1000
    });
  }

  /* -------------------------------------------- */
  /*  Properties and Public Methods               */
  /* -------------------------------------------- */

  /**
   * A convenience accessor to the Ruler for the active game user
   * @type {Ruler}
   */
  get ruler() {
    return this.getRulerForUser(game.user.id);
  }

  /* -------------------------------------------- */

  /**
   * Get the Ruler display for a specific User ID
   * @param {string} userId
   * @returns {Ruler|null}
   */
  getRulerForUser(userId) {
    return this._rulers[userId] || null;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);

    // Create additional elements
    this.drawCursors();
    this.drawRulers();
    this.drawDoors();
    this.select = this.cursors.addChild(new PIXI.Graphics());

    // Adjust scale
    const d = canvas.dimensions;
    this.hitArea = d.rect;
  }

  /* -------------------------------------------- */

  /** @override */
  async _tearDown(options) {
    this._cursors = {};
    this._rulers = {};
    this.doors.removeChildren();
    this.cursors.removeChildren();
    this.rulers.removeChildren();
    this.debug.clear();
    this.debug.debugText?.removeChildren().forEach(c => c.destroy({children: true}));
  }

  /* -------------------------------------------- */

  /**
   * Draw the cursors container
   */
  drawCursors() {
    for ( let u of game.users.filter(u => u.active && !u.isSelf ) ) {
      this.drawCursor(u);
    }
  }

  /* -------------------------------------------- */

  /**
   * Create and add Ruler graphics instances for every game User.
   */
  drawRulers() {
    const cls = CONFIG.Canvas.rulerClass;
    for (let u of game.users) {
      let ruler = this.getRulerForUser(u.id);
      if ( !ruler ) ruler = this._rulers[u.id] = new cls(u);
      this.rulers.addChild(ruler);
    }
  }

  /* -------------------------------------------- */

  /**
   * Draw door control icons to the doors container.
   */
  drawDoors() {
    for ( const wall of canvas.walls.placeables ) {
      if ( wall.isDoor ) wall.createDoorControl();
    }
  }

  /* -------------------------------------------- */

  /**
   * Draw the select rectangle given an event originated within the base canvas layer
   * @param {Object} coords   The rectangle coordinates of the form {x, y, width, height}
   */
  drawSelect({x, y, width, height}) {
    const s = this.select.clear();
    s.lineStyle(3, 0xFF9829, 0.9).drawRect(x, y, width, height);
  }

  /* -------------------------------------------- */

  /** @override */
  _deactivate() {
    this.interactiveChildren = true;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers
  /* -------------------------------------------- */

  /**
   * Handle mousemove events on the game canvas to broadcast activity of the user's cursor position
   */
  _onMouseMove() {
    if ( !game.user.hasPermission("SHOW_CURSOR") ) return;
    game.user.broadcastActivity({cursor: canvas.mousePosition});
  }

  /* -------------------------------------------- */

  /**
   * Handle pinging the canvas.
   * @param {PIXI.FederatedEvent}   event   The triggering canvas interaction event.
   * @param {PIXI.Point}            origin  The local canvas coordinates of the mousepress.
   * @protected
   */
  _onLongPress(event, origin) {
    const isCtrl = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
    const isTokenLayer = canvas.activeLayer instanceof TokenLayer;
    if ( !game.user.hasPermission("PING_CANVAS") || isCtrl || !isTokenLayer ) return;
    canvas.currentMouseManager.cancel(event);    // Cancel drag workflow
    return canvas.ping(origin);
  }

  /* -------------------------------------------- */

  /**
   * Handle the canvas panning to a new view.
   * @protected
   */
  _onCanvasPan() {
    for ( const [name, position] of Object.entries(this._offscreenPings) ) {
      const { ray, intersection } = this._findViewportIntersection(position);
      if ( intersection ) {
        const { x, y } = canvas.canvasCoordinatesFromClient(intersection);
        const ping = CanvasAnimation.getAnimation(name).context;
        ping.x = x;
        ping.y = y;
        ping.rotation = Math.normalizeRadians(ray.angle + (Math.PI * 1.5));
      } else CanvasAnimation.terminateAnimation(name);
    }
  }

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /**
   * Create and draw the Cursor object for a given User
   * @param {User} user   The User document for whom to draw the cursor Container
   */
  drawCursor(user) {
    if ( user.id in this._cursors ) {
      this._cursors[user.id].destroy({children: true});
      delete this._cursors[user.id];
    }
    return this._cursors[user.id] = this.cursors.addChild(new Cursor(user));
  }

  /* -------------------------------------------- */

  /**
   * Update the cursor when the user moves to a new position
   * @param {User} user         The User for whom to update the cursor
   * @param {Point} position    The new cursor position
   */
  updateCursor(user, position) {
    if ( !this.cursors ) return;
    const cursor = this._cursors[user.id] || this.drawCursor(user);

    // Ignore cursors on other Scenes
    if ( ( position === null ) || (user.viewedScene !== canvas.scene.id) ) {
      if ( cursor ) cursor.visible = false;
      return;
    }

    // Show the cursor in its currently tracked position
    cursor.refreshVisibility(user);
    cursor.target = {x: position.x || 0, y: position.y || 0};
  }

  /* -------------------------------------------- */

  /**
   * Update display of an active Ruler object for a user given provided data
   * @param {User} user                              The User for whom to update the ruler
   * @param {RulerMeasurementData|null} rulerData    Data which describes the new ruler measurement to display
   */
  updateRuler(user, rulerData) {

    // Ignore rulers for users who are not permitted to share
    if ( (user === game.user) || !user.hasPermission("SHOW_RULER") ) return;

    // Update the Ruler display for the user
    const ruler = this.getRulerForUser(user.id);
    ruler?.update(rulerData);
  }

  /* -------------------------------------------- */

  /**
   * Handle a broadcast ping.
   * @see {@link Ping#drawPing}
   * @param {User} user                 The user who pinged.
   * @param {Point} position            The position on the canvas that was pinged.
   * @param {PingData} [data]           The broadcast ping data.
   * @returns {Promise<boolean>}        A promise which resolves once the Ping has been drawn and animated
   */
  async handlePing(user, position, {scene, style="pulse", pull=false, zoom=1, ...pingOptions}={}) {
    if ( !canvas.ready || (canvas.scene?.id !== scene) || !position ) return;
    if ( pull && (user.isGM || user.isSelf) ) {
      await canvas.animatePan({
        x: position.x,
        y: position.y,
        scale: Math.min(CONFIG.Canvas.maxZoom, zoom),
        duration: CONFIG.Canvas.pings.pullSpeed
      });
    } else if ( canvas.isOffscreen(position) ) this.drawOffscreenPing(position, { style: "arrow", user });
    if ( game.settings.get("core", "photosensitiveMode") ) style = CONFIG.Canvas.pings.types.PULL;
    return this.drawPing(position, { style, user, ...pingOptions });
  }

  /* -------------------------------------------- */

  /**
   * Draw a ping at the edge of the viewport, pointing to the location of an off-screen ping.
   * @see {@link Ping#drawPing}
   * @param {Point} position                The coordinates of the off-screen ping.
   * @param {PingOptions} [options]         Additional options to configure how the ping is drawn.
   * @param {string} [options.style=arrow]  The style of ping to draw, from CONFIG.Canvas.pings.
   * @param {User} [options.user]           The user who pinged.
   * @returns {Promise<boolean>}            A promise which resolves once the Ping has been drawn and animated
   */
  async drawOffscreenPing(position, {style="arrow", user, ...pingOptions}={}) {
    const { ray, intersection } = this._findViewportIntersection(position);
    if ( !intersection ) return;
    const name = `Ping.${foundry.utils.randomID()}`;
    this._offscreenPings[name] = position;
    position = canvas.canvasCoordinatesFromClient(intersection);
    if ( game.settings.get("core", "photosensitiveMode") ) pingOptions.rings = 1;
    const animation = this.drawPing(position, { style, user, name, rotation: ray.angle, ...pingOptions });
    animation.finally(() => delete this._offscreenPings[name]);
    return animation;
  }

  /* -------------------------------------------- */

  /**
   * Draw a ping on the canvas.
   * @see {@link Ping#animate}
   * @param {Point} position                The position on the canvas that was pinged.
   * @param {PingOptions} [options]         Additional options to configure how the ping is drawn.
   * @param {string} [options.style=pulse]  The style of ping to draw, from CONFIG.Canvas.pings.
   * @param {User} [options.user]           The user who pinged.
   * @returns {Promise<boolean>}            A promise which resolves once the Ping has been drawn and animated
   */
  async drawPing(position, {style="pulse", user, ...pingOptions}={}) {
    const cfg = CONFIG.Canvas.pings.styles[style] ?? CONFIG.Canvas.pings.styles.pulse;
    const options = {
      duration: cfg.duration,
      color: cfg.color ?? user?.color,
      size: canvas.dimensions.size * (cfg.size || 1)
    };
    const ping = new cfg.class(position, foundry.utils.mergeObject(options, pingOptions));
    this.cursors.addChild(ping);
    return ping.animate();
  }

  /* -------------------------------------------- */

  /**
   * Given off-screen coordinates, determine the closest point at the edge of the viewport to these coordinates.
   * @param {Point} position                                     The off-screen coordinates.
   * @returns {{ray: Ray, intersection: LineIntersection|null}}  The closest point at the edge of the viewport to these
   *                                                             coordinates and a ray cast from the centre of the
   *                                                             screen towards it.
   * @private
   */
  _findViewportIntersection(position) {
    let { clientWidth: w, clientHeight: h } = document.documentElement;
    // Accommodate the sidebar.
    if ( !ui.sidebar._collapsed ) w -= ui.sidebar.options.width + 10;
    const [cx, cy] = [w / 2, h / 2];
    const ray = new Ray({x: cx, y: cy}, canvas.clientCoordinatesFromCanvas(position));
    const bounds = [[0, 0, w, 0], [w, 0, w, h], [w, h, 0, h], [0, h, 0, 0]];
    const intersections = bounds.map(ray.intersectSegment.bind(ray));
    const intersection = intersections.find(i => i !== null);
    return { ray, intersection };
  }
}

/**
 * @typedef {Object} RulerMeasurementSegment
 * @property {Ray} ray                      The Ray which represents the point-to-point line segment
 * @property {PreciseText} label            The text object used to display a label for this segment
 * @property {number} distance              The measured distance of the segment
 * @property {number} cost                  The measured cost of the segment
 * @property {number} cumulativeDistance    The cumulative measured distance of this segment and the segments before it
 * @property {number} cumulativeCost        The cumulative measured cost of this segment and the segments before it
 * @property {boolean} history              Is this segment part of the measurement history?
 * @property {boolean} first                Is this segment the first one after the measurement history?
 * @property {boolean} last                 Is this segment the last one?
 * @property {object} animation             Animation options passed to {@link TokenDocument#update}
 */

/**
 * @typedef {object} RulerMeasurementHistoryWaypoint
 * @property {number} x            The x-coordinate of the waypoint
 * @property {number} y            The y-coordinate of the waypoint
 * @property {boolean} teleport    Teleported to from the previous waypoint this waypoint?
 * @property {number} cost         The cost of having moved from the previous waypoint to this waypoint
 */

/**
 * @typedef {RulerMeasurementHistoryWaypoint[]} RulerMeasurementHistory
 */

/**
 * The Ruler - used to measure distances and trigger movements
 */
class Ruler extends PIXI.Container {
  /**
   * The Ruler constructor.
   * @param {User} [user=game.user]          The User for whom to construct the Ruler instance
   * @param {object} [options]               Additional options
   * @param {ColorSource} [options.color]    The color of the ruler (defaults to the color of the User)
   */
  constructor(user=game.user, {color}={}) {
    super();

    /**
     * Record the User which this Ruler references
     * @type {User}
     */
    this.user = user;

    /**
     * The ruler name - used to differentiate between players
     * @type {string}
     */
    this.name = `Ruler.${user.id}`;

    /**
     * The ruler color - by default the color of the active user
     * @type {Color}
     */
    this.color = Color.from(color ?? this.user.color);

    /**
     * The Ruler element is a Graphics instance which draws the line and points of the measured path
     * @type {PIXI.Graphics}
     */
    this.ruler = this.addChild(new PIXI.Graphics());

    /**
     * The Labels element is a Container of Text elements which label the measured path
     * @type {PIXI.Container}
     */
    this.labels = this.addChild(new PIXI.Container());
  }

  /* -------------------------------------------- */

  /**
   * The possible Ruler measurement states.
   * @enum {number}
   */
  static get STATES() {
    return Ruler.#STATES;
  }

  static #STATES = Object.freeze({
    INACTIVE: 0,
    STARTING: 1,
    MEASURING: 2,
    MOVING: 3
  });

  /* -------------------------------------------- */

  /**
   * Is the ruler ready for measure?
   * @type {boolean}
   */
  static get canMeasure() {
    return (game.activeTool === "ruler") || game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
  }

  /* -------------------------------------------- */

  /**
   * The current destination point at the end of the measurement
   * @type {Point|null}
   */
  destination = null;

  /* -------------------------------------------- */

  /**
   * The origin point of the measurement, which is the first waypoint.
   * @type {Point|null}
   */
  get origin() {
    return this.waypoints.at(0) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * This Array tracks individual waypoints along the ruler's measured path.
   * The first waypoint is always the origin of the route.
   * @type {Point[]}
   */
  waypoints = [];

  /* -------------------------------------------- */

  /**
   * The array of most recently computed ruler measurement segments
   * @type {RulerMeasurementSegment[]}
   */
  segments = [];

  /* -------------------------------------------- */

  /**
   * The measurement history.
   * @type {RulerMeasurementHistory}
   */
  get history() {
    return this.#history;
  }

  #history = [];

  /* -------------------------------------------- */

  /**
   * The computed total distance of the Ruler.
   * @type {number}
   */
  totalDistance = 0;

  /* -------------------------------------------- */

  /**
   * The computed total cost of the Ruler.
   * @type {number}
   */
  totalCost = 0;

  /* -------------------------------------------- */

  /**
   * The current state of the Ruler (one of {@link Ruler.STATES}).
   * @type {number}
   */
  get state() {
    return this._state;
  }

  /**
   * The current state of the Ruler (one of {@link Ruler.STATES}).
   * @type {number}
   * @protected
   */
  _state = Ruler.STATES.INACTIVE;

  /* -------------------------------------------- */

  /**
   * Is the Ruler being actively used to measure distance?
   * @type {boolean}
   */
  get active() {
    return this.state !== Ruler.STATES.INACTIVE;
  }

  /* -------------------------------------------- */

  /**
   * Get a GridHighlight layer for this Ruler
   * @type {GridHighlight}
   */
  get highlightLayer() {
    return canvas.interface.grid.highlightLayers[this.name] || canvas.interface.grid.addHighlightLayer(this.name);
  }

  /* -------------------------------------------- */

  /**
   * The Token that is moved by the Ruler.
   * @type {Token|null}
   */
  get token() {
    return this.#token;
  }

  #token = null;

  /* -------------------------------------------- */
  /*  Ruler Methods                               */
  /* -------------------------------------------- */

  /**
   * Clear display of the current Ruler
   */
  clear() {
    this._state = Ruler.STATES.INACTIVE;
    this.#token = null;
    this.destination = null;
    this.waypoints = [];
    this.segments = [];
    this.#history = [];
    this.totalDistance = 0;
    this.totalCost = 0;
    this.ruler.clear();
    this.labels.removeChildren().forEach(c => c.destroy());
    canvas.interface.grid.clearHighlightLayer(this.name);
  }

  /* -------------------------------------------- */

  /**
   * Measure the distance between two points and render the ruler UI to illustrate it
   * @param {Point} destination                        The destination point to which to measure
   * @param {object} [options]                         Additional options
   * @param {boolean} [options.snap=true]              Snap the destination?
   * @param {boolean} [options.force=false]            If not forced and the destination matches the current destination
   *                                                   of this ruler, no measuring is done and nothing is returned
   * @returns {RulerMeasurementSegment[]|void}         The array of measured segments if measured
   */
  measure(destination, {snap=true, force=false}={}) {
    if ( this.state !== Ruler.STATES.MEASURING ) return;

    // Compute the measurement destination, segments, and distance
    const d = this._getMeasurementDestination(destination, {snap});
    if ( this.destination && (d.x === this.destination.x) && (d.y === this.destination.y) && !force ) return;
    this.destination = d;
    this.segments = this._getMeasurementSegments();
    this._computeDistance();
    this._broadcastMeasurement();

    // Draw the ruler graphic
    this.ruler.clear();
    this._drawMeasuredPath();

    // Draw grid highlight
    this.highlightLayer.clear();
    for ( const segment of this.segments ) this._highlightMeasurementSegment(segment);
    return this.segments;
  }

  /* -------------------------------------------- */

  /**
   * Get the measurement origin.
   * @param {Point} point                    The waypoint
   * @param {object} [options]               Additional options
   * @param {boolean} [options.snap=true]    Snap the waypoint?
   * @protected
   */
  _getMeasurementOrigin(point, {snap=true}={}) {
    if ( this.token && snap ) {
      if ( canvas.grid.isGridless ) return this.token.getCenterPoint();
      const snapped = this.token.getSnappedPosition();
      const dx = this.token.document.x - Math.round(snapped.x);
      const dy = this.token.document.y - Math.round(snapped.y);
      const center = canvas.grid.getCenterPoint({x: point.x - dx, y: point.y - dy});
      return {x: center.x + dx, y: center.y + dy};
    }
    return snap ? canvas.grid.getCenterPoint(point) : {x: point.x, y: point.y};
  }

  /* -------------------------------------------- */

  /**
   * Get the destination point. By default the point is snapped to grid space centers.
   * @param {Point} point                    The point coordinates
   * @param {object} [options]               Additional options
   * @param {boolean} [options.snap=true]    Snap the point?
   * @returns {Point}                        The snapped destination point
   * @protected
   */
  _getMeasurementDestination(point, {snap=true}={}) {
    return snap ? canvas.grid.getCenterPoint(point) : {x: point.x, y: point.y};
  }

  /* -------------------------------------------- */

  /**
   * Translate the waypoints and destination point of the Ruler into an array of Ray segments.
   * @returns {RulerMeasurementSegment[]} The segments of the measured path
   * @protected
   */
  _getMeasurementSegments() {
    const segments = [];
    const path = this.history.concat(this.waypoints.concat([this.destination]));
    for ( let i = 1; i < path.length; i++ ) {
      const label = this.labels.children.at(i - 1) ?? this.labels.addChild(new PreciseText("", CONFIG.canvasTextStyle));
      const ray = new Ray(path[i - 1], path[i]);
      segments.push({
        ray,
        teleport: (i < this.history.length) ? path[i].teleport : (i === this.history.length) && (ray.distance > 0),
        label,
        distance: 0,
        cost: 0,
        cumulativeDistance: 0,
        cumulativeCost: 0,
        history: i <= this.history.length,
        first: i === this.history.length + 1,
        last: i === path.length - 1,
        animation: {}
      });
    }
    if ( this.labels.children.length > segments.length ) {
      this.labels.removeChildren(segments.length).forEach(c => c.destroy());
    }
    return segments;
  }

  /* -------------------------------------------- */

  /**
   * Handle the start of a Ruler measurement workflow
   * @param {Point} origin                   The origin
   * @param {object} [options]               Additional options
   * @param {boolean} [options.snap=true]    Snap the origin?
   * @param {Token|null} [options.token]     The token that is moved (defaults to {@link Ruler#_getMovementToken})
   * @protected
   */
  _startMeasurement(origin, {snap=true, token}={}) {
    if ( this.state !== Ruler.STATES.INACTIVE ) return;
    this.clear();
    this._state = Ruler.STATES.STARTING;
    this.#token = token !== undefined ? token : this._getMovementToken(origin);
    this.#history = this._getMeasurementHistory() ?? [];
    this._addWaypoint(origin, {snap});
    canvas.hud.token.clear();
  }

  /* -------------------------------------------- */

  /**
   * Handle the conclusion of a Ruler measurement workflow
   * @protected
   */
  _endMeasurement() {
    if ( this.state !== Ruler.STATES.MEASURING ) return;
    this.clear();
    this._broadcastMeasurement();
  }

  /* -------------------------------------------- */

  /**
   * Handle the addition of a new waypoint in the Ruler measurement path
   * @param {Point} point                    The waypoint
   * @param {object} [options]               Additional options
   * @param {boolean} [options.snap=true]    Snap the waypoint?
   * @protected
   */
  _addWaypoint(point, {snap=true}={}) {
    if ( (this.state !== Ruler.STATES.STARTING) && (this.state !== Ruler.STATES.MEASURING ) ) return;
    const waypoint = this.state === Ruler.STATES.STARTING
      ? this._getMeasurementOrigin(point, {snap})
      : this._getMeasurementDestination(point, {snap});
    this.waypoints.push(waypoint);
    this._state = Ruler.STATES.MEASURING;
    this.measure(this.destination ?? point, {snap, force: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle the removal of a waypoint in the Ruler measurement path
   * @protected
   */
  _removeWaypoint() {
    if ( (this.state !== Ruler.STATES.STARTING) && (this.state !== Ruler.STATES.MEASURING ) ) return;
    if ( (this.state === Ruler.STATES.MEASURING) && (this.waypoints.length > 1) ) {
      this.waypoints.pop();
      this.measure(this.destination, {snap: false, force: true});
    }
    else this._endMeasurement();
  }

  /* -------------------------------------------- */

  /**
   * Get the cost function to be used for Ruler measurements.
   * @returns {GridMeasurePathCostFunction|void}
   * @protected
   */
  _getCostFunction() {}

  /* -------------------------------------------- */

  /**
   * Compute the distance of each segment and the total distance of the measured path.
   * @protected
   */
  _computeDistance() {
    let path = [];
    if ( this.segments.length ) path.push(this.segments[0].ray.A);
    for ( const segment of this.segments ) {
      const {x, y} = segment.ray.B;
      path.push({x, y, teleport: segment.teleport});
    }
    const measurements = canvas.grid.measurePath(path, {cost: this._getCostFunction()}).segments;
    this.totalDistance = 0;
    this.totalCost = 0;
    for ( let i = 0; i < this.segments.length; i++ ) {
      const segment = this.segments[i];
      const distance = measurements[i].distance;
      const cost = segment.history ? this.history.at(i + 1)?.cost ?? 0 : measurements[i].cost;
      this.totalDistance += distance;
      this.totalCost += cost;
      segment.distance = distance;
      segment.cost = cost;
      segment.cumulativeDistance = this.totalDistance;
      segment.cumulativeCost = this.totalCost;
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the text label for a segment of the measured path
   * @param {RulerMeasurementSegment} segment
   * @returns {string}
   * @protected
   */
  _getSegmentLabel(segment) {
    if ( segment.teleport ) return "";
    const units = canvas.grid.units;
    let label = `${Math.round(segment.distance * 100) / 100}`;
    if ( units ) label += ` ${units}`;
    if ( segment.last ) {
      label += ` [${Math.round(this.totalDistance * 100) / 100}`;
      if ( units ) label += ` ${units}`;
      label += "]";
    }
    return label;
  }

  /* -------------------------------------------- */

  /**
   * Draw each segment of the measured path.
   * @protected
   */
  _drawMeasuredPath() {
    const paths = [];
    let path = null;
    for ( const segment of this.segments ) {
      const ray = segment.ray;
      if ( ray.distance !== 0 ) {
        if ( segment.teleport ) path = null;
        else {
          if ( !path || (path.history !== segment.history) ) {
            path = {points: [ray.A], history: segment.history};
            paths.push(path);
          }
          path.points.push(ray.B);
        }
      }

      // Draw Label
      const label = segment.label;
      if ( label ) {
        const text = this._getSegmentLabel(segment, /** @deprecated since v12 */ this.totalDistance);
        label.text = text;
        label.alpha = segment.last ? 1.0 : 0.5;
        label.visible = !!text && (ray.distance !== 0);
        label.anchor.set(0.5, 0.5);
        let {sizeX, sizeY} = canvas.grid;
        if ( canvas.grid.isGridless ) sizeX = sizeY = 6; // The radius of the waypoints
        const pad = 8;
        const offsetX = (label.width + (2 * pad) + sizeX) / Math.abs(2 * ray.dx);
        const offsetY = (label.height + (2 * pad) + sizeY) / Math.abs(2 * ray.dy);
        label.position = ray.project(1 + Math.min(offsetX, offsetY));
      }
    }
    const points = paths.map(p => p.points).flat();

    // Draw segments
    if ( points.length === 1 ) {
      this.ruler.beginFill(0x000000, 0.5, true).drawCircle(points[0].x, points[0].y, 3).endFill();
      this.ruler.beginFill(this.color, 0.25, true).drawCircle(points[0].x, points[0].y, 2).endFill();
    } else {
      const dashShader = new PIXI.smooth.DashLineShader();
      for ( const {points, history} of paths ) {
        this.ruler.lineStyle({width: 6, color: 0x000000, alpha: 0.5, shader: history ? dashShader : null,
          join: PIXI.LINE_JOIN.ROUND, cap: PIXI.LINE_CAP.ROUND});
        this.ruler.drawPath(points);
        this.ruler.lineStyle({width: 4, color: this.color, alpha: 0.25, shader: history ? dashShader : null,
          join: PIXI.LINE_JOIN.ROUND, cap: PIXI.LINE_CAP.ROUND});
        this.ruler.drawPath(points);
      }
    }

    // Draw waypoints
    this.ruler.beginFill(this.color, 0.25, true).lineStyle(2, 0x000000, 0.5);
    for ( const {x, y} of points ) this.ruler.drawCircle(x, y, 6);
    this.ruler.endFill();
  }

  /* -------------------------------------------- */

  /**
   * Highlight the measurement required to complete the move in the minimum number of discrete spaces
   * @param {RulerMeasurementSegment} segment
   * @protected
   */
  _highlightMeasurementSegment(segment) {
    if ( segment.teleport ) return;
    for ( const offset of canvas.grid.getDirectPath([segment.ray.A, segment.ray.B]) ) {
      const {x: x1, y: y1} = canvas.grid.getTopLeftPoint(offset);
      canvas.interface.grid.highlightPosition(this.name, {x: x1, y: y1, color: this.color});
    }
  }

  /* -------------------------------------------- */
  /*  Token Movement Execution                    */
  /* -------------------------------------------- */

  /**
   * Determine whether a SPACE keypress event entails a legal token movement along a measured ruler
   * @returns {Promise<boolean>}  An indicator for whether a token was successfully moved or not. If True the
   *                              event should be prevented from propagating further, if False it should move on
   *                              to other handlers.
   */
  async moveToken() {
    if ( this.state !== Ruler.STATES.MEASURING ) return false;
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return false;
    }

    // Get the Token which should move
    const token = this.token;
    if ( !token ) return false;

    // Verify whether the movement is allowed
    let error;
    try {
      if ( !this._canMove(token) ) error = "RULER.MovementNotAllowed";
    } catch(err) {
      error = err.message;
    }
    if ( error ) {
      ui.notifications.error(error, {localize: true});
      return false;
    }

    // Animate the movement path defined by each ray segments
    this._state = Ruler.STATES.MOVING;
    await this._preMove(token);
    await this._animateMovement(token);
    await this._postMove(token);

    // Clear the Ruler
    this._state = Ruler.STATES.MEASURING;
    this._endMeasurement();
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Acquire a Token, if any, which is eligible to perform a movement based on the starting point of the Ruler
   * @param {Point} origin    The origin of the Ruler
   * @returns {Token|null}    The Token that is to be moved, if any
   * @protected
   */
  _getMovementToken(origin) {
    let tokens = canvas.tokens.controlled;
    if ( !tokens.length && game.user.character ) tokens = game.user.character.getActiveTokens();
    for ( const token of tokens ) {
      if ( !token.visible || !token.shape ) continue;
      const {x, y} = token.document;
      for ( let dx = -1; dx <= 1; dx++ ) {
        for ( let dy = -1; dy <= 1; dy++ ) {
          if ( token.shape.contains(origin.x - x + dx, origin.y - y + dy) ) return token;
        }
      }
    }
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Get the current measurement history.
   * @returns {RulerMeasurementHistory|void}    The current measurement history, if any
   * @protected
   */
  _getMeasurementHistory() {}

  /* -------------------------------------------- */

  /**
   * Create the next measurement history from the current history and current Ruler state.
   * @returns {RulerMeasurementHistory}    The next measurement history
   * @protected
   */
  _createMeasurementHistory() {
    if ( !this.segments.length ) return [];
    const origin = this.segments[0].ray.A;
    return this.segments.reduce((history, s) => {
      if ( s.ray.distance === 0 ) return history;
      history.push({x: s.ray.B.x, y: s.ray.B.y, teleport: s.teleport, cost: s.cost});
      return history;
    }, [{x: origin.x, y: origin.y, teleport: false, cost: 0}]);
  }

  /* -------------------------------------------- */

  /**
   * Test whether a Token is allowed to execute a measured movement path.
   * @param {Token} token       The Token being tested
   * @returns {boolean}         Whether the movement is allowed
   * @throws                    A specific Error message used instead of returning false
   * @protected
   */
  _canMove(token) {
    const canUpdate = token.document.canUserModify(game.user, "update");
    if ( !canUpdate ) throw new Error("RULER.MovementNoPermission");
    if ( token.document.locked ) throw new Error("RULER.MovementLocked");
    const hasCollision = this.segments.some(s => {
      return token.checkCollision(s.ray.B, {origin: s.ray.A, type: "move", mode: "any"});
    });
    if ( hasCollision ) throw new Error("RULER.MovementCollision");
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Animate piecewise Token movement along the measured segment path.
   * @param {Token} token           The Token being animated
   * @returns {Promise<void>}       A Promise which resolves once all animation is completed
   * @protected
   */
  async _animateMovement(token) {
    const wasPaused = game.paused;

    // Determine offset of the initial origin relative to the snapped Token's top-left.
    // This is important to position the token relative to the ruler origin for non-1x1 tokens.
    const origin = this.segments[this.history.length].ray.A;
    const dx = token.document.x - origin.x;
    const dy = token.document.y - origin.y;

    // Iterate over each measured segment
    let priorDest = undefined;
    for ( const segment of this.segments ) {
      if ( segment.history || (segment.ray.distance === 0) ) continue;
      const r = segment.ray;
      const {x, y} = token.document._source;

      // Break the movement if the game is paused
      if ( !wasPaused && game.paused ) break;

      // Break the movement if Token is no longer located at the prior destination (some other change override this)
      if ( priorDest && ((x !== priorDest.x) || (y !== priorDest.y)) ) break;

      // Commit the movement and update the final resolved destination coordinates
      const adjustedDestination = {x: Math.round(r.B.x + dx), y: Math.round(r.B.y + dy)};
      await this._animateSegment(token, segment, adjustedDestination);
      priorDest = adjustedDestination;
    }
  }

  /* -------------------------------------------- */

  /**
   * Update Token position and configure its animation properties for the next leg of its animation.
   * @param {Token} token                         The Token being updated
   * @param {RulerMeasurementSegment} segment     The measured segment being moved
   * @param {Point} destination                   The adjusted destination coordinate
   * @param {object} [updateOptions]              Additional options to configure the `TokenDocument` update
   * @returns {Promise<void>}                     A Promise that resolves once the animation for this segment is done
   * @protected
   */
  async _animateSegment(token, segment, destination, updateOptions={}) {
    let name;
    if ( segment.animation?.name === undefined ) name = token.animationName;
    else name ||= Symbol(token.animationName);
    const {x, y} = token.document._source;
    await token.animate({x, y}, {name, duration: 0});
    foundry.utils.mergeObject(
      updateOptions,
      {teleport: segment.teleport, animation: {...segment.animation, name}},
      {overwrite: false}
    );
    await token.document.update(destination, updateOptions);
    await CanvasAnimation.getAnimation(name)?.promise;
  }

  /* -------------------------------------------- */

  /**
   * An method which can be extended by a subclass of Ruler to define custom behaviors before a confirmed movement.
   * @param {Token} token       The Token that will be moving
   * @returns {Promise<void>}
   * @protected
   */
  async _preMove(token) {}

  /* -------------------------------------------- */

  /**
   * An event which can be extended by a subclass of Ruler to define custom behaviors before a confirmed movement.
   * @param {Token} token       The Token that finished moving
   * @returns {Promise<void>}
   * @protected
   */
  async _postMove(token) {}

  /* -------------------------------------------- */
  /*  Saving and Loading
  /* -------------------------------------------- */

  /**
   * A throttled function that broadcasts the measurement data.
   * @type {function()}
   */
  #throttleBroadcastMeasurement = foundry.utils.throttle(this.#broadcastMeasurement.bind(this), 100);

  /* -------------------------------------------- */

  /**
   * Broadcast Ruler measurement.
   */
  #broadcastMeasurement() {
    game.user.broadcastActivity({ruler: this.active ? this._getMeasurementData() : null});
  }

  /* -------------------------------------------- */

  /**
   * Broadcast Ruler measurement if its User is the connected client.
   * The broadcast is throttled to 100ms.
   * @protected
   */
  _broadcastMeasurement() {
    if ( !this.user.isSelf || !game.user.hasPermission("SHOW_RULER") ) return;
    this.#throttleBroadcastMeasurement();
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} RulerMeasurementData
   * @property {number} state                       The state ({@link Ruler#state})
   * @property {string|null} token                  The token ID ({@link Ruler#token})
   * @property {RulerMeasurementHistory} history    The measurement history ({@link Ruler#history})
   * @property {Point[]} waypoints                  The waypoints ({@link Ruler#waypoints})
   * @property {Point|null} destination             The destination ({@link Ruler#destination})
   */

  /**
   * Package Ruler data to an object which can be serialized to a string.
   * @returns {RulerMeasurementData}
   * @protected
   */
  _getMeasurementData() {
    return foundry.utils.deepClone({
      state: this.state,
      token: this.token?.id ?? null,
      history: this.history,
      waypoints: this.waypoints,
      destination: this.destination
    });
  }

  /* -------------------------------------------- */

  /**
   * Update a Ruler instance using data provided through the cursor activity socket
   * @param {RulerMeasurementData|null} data   Ruler data with which to update the display
   */
  update(data) {
    if ( !data || (data.state === Ruler.STATES.INACTIVE) ) return this.clear();
    this._state = data.state;
    this.#token = canvas.tokens.get(data.token) ?? null;
    this.#history = data.history;
    this.waypoints = data.waypoints;
    this.measure(data.destination, {snap: false, force: true});
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers
  /* -------------------------------------------- */

  /**
   * Handle the beginning of a new Ruler measurement workflow
   * @see {Canvas.#onDragLeftStart}
   * @param {PIXI.FederatedEvent} event   The drag start event
   * @protected
   * @internal
   */
  _onDragStart(event) {
    this._startMeasurement(event.interactionData.origin, {snap: !event.shiftKey});
    if ( this.token && (this.state === Ruler.STATES.MEASURING) ) this.token.document.locked = true;
  }

  /* -------------------------------------------- */

  /**
   * Handle left-click events on the Canvas during Ruler measurement.
   * @see {Canvas._onClickLeft}
   * @param {PIXI.FederatedEvent} event   The pointer-down event
   * @protected
   * @internal
   */
  _onClickLeft(event) {
    const isCtrl = event.ctrlKey || event.metaKey;
    if ( !isCtrl ) return;
    this._addWaypoint(event.interactionData.origin, {snap: !event.shiftKey});
  }

  /* -------------------------------------------- */

  /**
   * Handle right-click events on the Canvas during Ruler measurement.
   * @see {Canvas._onClickRight}
   * @param {PIXI.FederatedEvent} event   The pointer-down event
   * @protected
   * @internal
   */
  _onClickRight(event) {
    const token = this.token;
    const isCtrl = event.ctrlKey || event.metaKey;
    if ( isCtrl ) this._removeWaypoint();
    else this._endMeasurement();
    if ( this.active ) canvas.mouseInteractionManager._dragRight = false;
    else {
      if ( token ) token.document.locked = token.document._source.locked;
      canvas.mouseInteractionManager.cancel(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Continue a Ruler measurement workflow for left-mouse movements on the Canvas.
   * @see {Canvas.#onDragLeftMove}
   * @param {PIXI.FederatedEvent} event   The mouse move event
   * @protected
   * @internal
   */
  _onMouseMove(event) {
    const destination = event.interactionData.destination;
    if ( !canvas.dimensions.rect.contains(destination.x, destination.y) ) return;
    this.measure(destination, {snap: !event.shiftKey});
  }

  /* -------------------------------------------- */

  /**
   * Conclude a Ruler measurement workflow by releasing the left-mouse button.
   * @see {Canvas.#onDragLeftDrop}
   * @param {PIXI.FederatedEvent} event   The pointer-up event
   * @protected
   * @internal
   */
  _onMouseUp(event) {
    if ( !this.active ) return;
    const isCtrl = event.ctrlKey || event.metaKey;
    if ( isCtrl || (this.waypoints.length > 1) ) event.preventDefault();
    else {
      if ( this.token ) this.token.document.locked = this.token.document._source.locked;
      this._endMeasurement();
      canvas.mouseInteractionManager.cancel(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Move the Token along the measured path when the move key is pressed.
   * @param {KeyboardEventContext} context
   * @protected
   * @internal
   */
  _onMoveKeyDown(context) {
    if ( this.token ) this.token.document.locked = this.token.document._source.locked;
    // noinspection ES6MissingAwait
    this.moveToken();
    if ( this.state !== Ruler.STATES.MEASURING ) canvas.mouseInteractionManager.cancel();
  }
}

/**
 * A layer of background alteration effects which change the appearance of the primary group render texture.
 * @category - Canvas
 */
class CanvasBackgroundAlterationEffects extends CanvasLayer {
  constructor() {
    super();

    /**
     * A collection of effects which provide background vision alterations.
     * @type {PIXI.Container}
     */
    this.vision = this.addChild(new PIXI.Container());
    this.vision.sortableChildren = true;

    /**
     * A collection of effects which provide background preferred vision alterations.
     * @type {PIXI.Container}
     */
    this.visionPreferred = this.addChild(new PIXI.Container());
    this.visionPreferred.sortableChildren = true;

    /**
     * A collection of effects which provide other background alterations.
     * @type {PIXI.Container}
     */
    this.lighting = this.addChild(new PIXI.Container());
    this.lighting.sortableChildren = true;
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {

    // Add the background vision filter
    const vf = this.vision.filter = new VoidFilter();
    vf.blendMode = PIXI.BLEND_MODES.NORMAL;
    vf.enabled = false;
    this.vision.filters = [vf];
    this.vision.filterArea = canvas.app.renderer.screen;

    // Add the background preferred vision filter
    const vpf = this.visionPreferred.filter = new VoidFilter();
    vpf.blendMode = PIXI.BLEND_MODES.NORMAL;
    vpf.enabled = false;
    this.visionPreferred.filters = [vpf];
    this.visionPreferred.filterArea = canvas.app.renderer.screen;

    // Add the background lighting filter
    const maskingFilter = CONFIG.Canvas.visualEffectsMaskingFilter;
    const lf = this.lighting.filter = maskingFilter.create({
      visionTexture: canvas.masks.vision.renderTexture,
      darknessLevelTexture: canvas.effects.illumination.renderTexture,
      mode: maskingFilter.FILTER_MODES.BACKGROUND
    });
    lf.blendMode = PIXI.BLEND_MODES.NORMAL;
    this.lighting.filters = [lf];
    this.lighting.filterArea = canvas.app.renderer.screen;
    canvas.effects.visualEffectsMaskingFilters.add(lf);
  }

  /* -------------------------------------------- */

  /** @override */
  async _tearDown(options) {
    canvas.effects.visualEffectsMaskingFilters.delete(this.lighting?.filter);
    this.clear();
  }

  /* -------------------------------------------- */

  /**
   * Clear background alteration effects vision and lighting containers
   */
  clear() {
    this.vision.removeChildren();
    this.visionPreferred.removeChildren();
    this.lighting.removeChildren();
  }
}

/**
 * A CanvasLayer for displaying coloration visual effects
 * @category - Canvas
 */
class CanvasColorationEffects extends CanvasLayer {
  constructor() {
    super();
    this.sortableChildren = true;
    this.#background = this.addChild(new PIXI.LegacyGraphics());
    this.#background.zIndex = -Infinity;
  }

  /**
   * Temporary solution for the "white scene" bug (foundryvtt/foundryvtt#9957).
   * @type {PIXI.LegacyGraphics}
   */
  #background;

  /**
   * The filter used to mask visual effects on this layer
   * @type {VisualEffectsMaskingFilter}
   */
  filter;

  /* -------------------------------------------- */

  /**
   * Clear coloration effects container
   */
  clear() {
    this.removeChildren();
    this.addChild(this.#background);
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    const maskingFilter = CONFIG.Canvas.visualEffectsMaskingFilter;
    this.filter = maskingFilter.create({
      visionTexture: canvas.masks.vision.renderTexture,
      darknessLevelTexture: canvas.effects.illumination.renderTexture,
      mode: maskingFilter.FILTER_MODES.COLORATION
    });
    this.filter.blendMode = PIXI.BLEND_MODES.ADD;
    this.filterArea = canvas.app.renderer.screen;
    this.filters = [this.filter];
    canvas.effects.visualEffectsMaskingFilters.add(this.filter);
    this.#background.clear().beginFill().drawShape(canvas.dimensions.rect).endFill();
  }

  /* -------------------------------------------- */

  /** @override */
  async _tearDown(options) {
    canvas.effects.visualEffectsMaskingFilters.delete(this.filter);
    this.#background.clear();
  }
}

/**
 * A layer of background alteration effects which change the appearance of the primary group render texture.
 * @category - Canvas
 */
class CanvasDarknessEffects extends CanvasLayer {
  constructor() {
    super();
    this.sortableChildren = true;
  }

  /* -------------------------------------------- */

  /**
   * Clear coloration effects container
   */
  clear() {
    this.removeChildren();
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.filter = VoidFilter.create();
    this.filter.blendMode = PIXI.BLEND_MODES.NORMAL;
    this.filterArea = canvas.app.renderer.screen;
    this.filters = [this.filter];
  }
}

/**
 * A CanvasLayer for displaying illumination visual effects
 * @category - Canvas
 */
class CanvasIlluminationEffects extends CanvasLayer {
  constructor() {
    super();
    this.#initialize();
  }

  /**
   * The filter used to mask visual effects on this layer
   * @type {VisualEffectsMaskingFilter}
   */
  filter;

  /**
   * The container holding the lights.
   * @type {PIXI.Container}
   */
  lights = new PIXI.Container();

  /**
   * A minimalist texture that holds the background color.
   * @type {PIXI.Texture}
   */
  backgroundColorTexture;

  /**
   * The background color rgb array.
   * @type {number[]}
   */
  #backgroundColorRGB;

  /**
   * The base line mesh.
   * @type {SpriteMesh}
   */
  baselineMesh = new SpriteMesh();

  /**
   * The cached container holding the illumination meshes.
   * @type {CachedContainer}
   */
  darknessLevelMeshes = new DarknessLevelContainer();

  /* -------------------------------------------- */

  /**
   * To know if dynamic darkness level is active on this scene.
   * @returns {boolean}
   */
  get hasDynamicDarknessLevel() {
    return this.darknessLevelMeshes.children.length > 0;
  }

  /**
   * The illumination render texture.
   * @returns {PIXI.RenderTexture}
   */
  get renderTexture() {
    return this.darknessLevelMeshes.renderTexture;
  }

  /* -------------------------------------------- */

  /**
   * Initialize the layer.
   */
  #initialize() {
    // Configure background color texture
    this.backgroundColorTexture = this._createBackgroundColorTexture();

    // Configure the base line mesh
    this.baselineMesh.setShaderClass(BaselineIlluminationSamplerShader);
    this.baselineMesh.texture = this.darknessLevelMeshes.renderTexture;

    // Add children
    canvas.masks.addChild(this.darknessLevelMeshes);               // Region meshes cached container
    this.addChild(this.lights);                                    // Light and vision illumination

    // Add baseline rendering for light
    const originalRender = this.lights.render;
    const baseMesh = this.baselineMesh;
    this.lights.render = renderer => {
      baseMesh.render(renderer);
      originalRender.call(this.lights, renderer);
    };

    // Configure
    this.lights.sortableChildren = true;
  }

  /* -------------------------------------------- */

  /**
   * Set or retrieve the illumination background color.
   * @param {number} color
   */
  set backgroundColor(color) {
    const cb = this.#backgroundColorRGB = Color.from(color).rgb;
    if ( this.filter ) this.filter.uniforms.replacementColor = cb;
    this.backgroundColorTexture.baseTexture.resource.data.set(cb);
    this.backgroundColorTexture.baseTexture.resource.update();
  }

  /* -------------------------------------------- */

  /**
   * Clear illumination effects container
   */
  clear() {
    this.lights.removeChildren();
  }

  /* -------------------------------------------- */

  /**
   * Invalidate the cached container state to trigger a render pass.
   * @param {boolean} [force=false] Force cached container invalidation?
   */
  invalidateDarknessLevelContainer(force=false) {
    // If global light is enabled, the darkness level texture is affecting the vision mask
    if ( canvas.environment.globalLightSource.active ) canvas.masks.vision.renderDirty = true;
    if ( !(this.hasDynamicDarknessLevel || force) ) return;
    this.darknessLevelMeshes.renderDirty = true;
    // Sort by adjusted darkness level in descending order such that the final darkness level
    // at a point is the minimum of the adjusted darkness levels
    const compare = (a, b) => b.shader.darknessLevel - a.shader.darknessLevel;
    this.darknessLevelMeshes.children.sort(compare);
    canvas.visibility.vision.light.global.meshes.children.sort(compare);
  }

  /* -------------------------------------------- */

  /**
   * Create the background color texture used by illumination point source meshes.
   * 1x1 single pixel texture.
   * @returns {PIXI.Texture}    The background color texture.
   * @protected
   */
  _createBackgroundColorTexture() {
    return PIXI.Texture.fromBuffer(new Float32Array(3), 1, 1, {
      type: PIXI.TYPES.FLOAT,
      format: PIXI.FORMATS.RGB,
      wrapMode: PIXI.WRAP_MODES.CLAMP,
      scaleMode: PIXI.SCALE_MODES.NEAREST,
      mipmap: PIXI.MIPMAP_MODES.OFF
    });
  }

  /* -------------------------------------------- */

  /** @override */
  render(renderer) {
    // Prior blend mode is reinitialized. The first render into PointSourceMesh will use the background color texture.
    PointSourceMesh._priorBlendMode = undefined;
    PointSourceMesh._currentTexture = this.backgroundColorTexture;
    super.render(renderer);
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    const maskingFilter = CONFIG.Canvas.visualEffectsMaskingFilter;
    this.darknessLevel = canvas.darknessLevel;
    this.filter = maskingFilter.create({
      visionTexture: canvas.masks.vision.renderTexture,
      darknessLevelTexture: canvas.effects.illumination.renderTexture,
      mode: maskingFilter.FILTER_MODES.ILLUMINATION
    });
    this.filter.blendMode = PIXI.BLEND_MODES.MULTIPLY;
    this.filterArea = canvas.app.renderer.screen;
    this.filters = [this.filter];
    canvas.effects.visualEffectsMaskingFilters.add(this.filter);
  }

  /* -------------------------------------------- */

  /** @override */
  async _tearDown(options) {
    canvas.effects.visualEffectsMaskingFilters.delete(this.filter);
    this.clear();
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  updateGlobalLight() {
    const msg = "CanvasIlluminationEffects#updateGlobalLight has been deprecated.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return false;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  background() {
    const msg = "CanvasIlluminationEffects#background is now obsolete.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return null;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  get globalLight() {
    const msg = "CanvasIlluminationEffects#globalLight has been deprecated without replacement. Check the" +
      "canvas.environment.globalLightSource.active instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return canvas.environment.globalLightSource.active;
  }
}

/**
 * Cached container used for dynamic darkness level. Display objects (of any type) added to this cached container will
 * contribute to computing the darkness level of the masked area. Only the red channel is utilized, which corresponds
 * to the desired darkness level. Other channels are ignored.
 */
class DarknessLevelContainer extends CachedContainer {
  constructor(...args) {
    super(...args);
    this.autoRender = false;
    this.on("childAdded", this.#onChildChange);
    this.on("childRemoved", this.#onChildChange);
  }

  /** @override */
  static textureConfiguration = {
    scaleMode: PIXI.SCALE_MODES.NEAREST,
    format: PIXI.FORMATS.RED,
    multisample: PIXI.MSAA_QUALITY.NONE,
    mipmap: PIXI.MIPMAP_MODES.OFF
  };

  /**
   * Called when a display object is added or removed from this container.
   */
  #onChildChange() {
    this.autoRender = this.children.length > 0;
    this.renderDirty = true;
    canvas.perception.update({refreshVisionSources: true, refreshLightSources: true});
  }
}


// noinspection JSPrimitiveTypeWrapperUsage
/**
 * The visibility Layer which implements dynamic vision, lighting, and fog of war
 * This layer uses an event-driven workflow to perform the minimal required calculation in response to changes.
 * @see {@link PointSource}
 *
 * ### Hook Events
 * - {@link hookEvents.visibilityRefresh}
 *
 * @category - Canvas
 */
class CanvasVisibility extends CanvasLayer {
  /**
   * The currently revealed vision.
   * @type {CanvasVisionContainer}
   */
  vision;

  /**
   * The exploration container which tracks exploration progress.
   * @type {PIXI.Container}
   */
  explored;

  /**
   * The optional visibility overlay sprite that should be drawn instead of the unexplored color in the fog of war.
   * @type {PIXI.Sprite}
   */
  visibilityOverlay;

  /**
   * The graphics used to render cached light sources.
   * @type {PIXI.LegacyGraphics}
   */
  #cachedLights = new PIXI.LegacyGraphics();

  /**
   * Matrix used for visibility rendering transformation.
   * @type {PIXI.Matrix}
   */
  #renderTransform = new PIXI.Matrix();

  /**
   * Dimensions of the visibility overlay texture and base texture used for tiling texture into the visibility filter.
   * @type {number[]}
   */
  #visibilityOverlayDimensions;

  /**
   * The active vision source data object
   * @type {{source: VisionSource|null, activeLightingOptions: object}}
   */
  visionModeData = {
    source: undefined,
    activeLightingOptions: {}
  };

  /**
   * Define whether each lighting layer is enabled, required, or disabled by this vision mode.
   * The value for each lighting channel is a number in LIGHTING_VISIBILITY
   * @type {{illumination: number, background: number, coloration: number,
   * darkness: number, any: boolean}}
   */
  lightingVisibility = {
    background: VisionMode.LIGHTING_VISIBILITY.ENABLED,
    illumination: VisionMode.LIGHTING_VISIBILITY.ENABLED,
    coloration: VisionMode.LIGHTING_VISIBILITY.ENABLED,
    darkness: VisionMode.LIGHTING_VISIBILITY.ENABLED,
    any: true
  };

  /**
   * The map with the active cached light source IDs as keys and their update IDs as values.
   * @type {Map<string, number>}
   */
  #cachedLightSourceStates = new Map();

  /**
   * The maximum allowable visibility texture size.
   * @type {number}
   */
  static #MAXIMUM_VISIBILITY_TEXTURE_SIZE = 4096;

  /* -------------------------------------------- */
  /*  Canvas Visibility Properties                */
  /* -------------------------------------------- */

  /**
   * A status flag for whether the layer initialization workflow has succeeded.
   * @type {boolean}
   */
  get initialized() {
    return this.#initialized;
  }

  #initialized = false;

  /* -------------------------------------------- */

  /**
   * Indicates whether containment filtering is required when rendering vision into a texture.
   * @type {boolean}
   * @internal
   */
  get needsContainment() {
    return this.#needsContainment;
  }

  #needsContainment = false;

  /* -------------------------------------------- */

  /**
   * Does the currently viewed Scene support Token field of vision?
   * @type {boolean}
   */
  get tokenVision() {
    return canvas.scene.tokenVision;
  }

  /* -------------------------------------------- */

  /**
   * The configured options used for the saved fog-of-war texture.
   * @type {FogTextureConfiguration}
   */
  get textureConfiguration() {
    return this.#textureConfiguration;
  }

  /** @private */
  #textureConfiguration;

  /* -------------------------------------------- */

  /**
   * Optional overrides for exploration sprite dimensions.
   * @type {FogTextureConfiguration}
   */
  set explorationRect(rect) {
    this.#explorationRect = rect;
  }

  /** @private */
  #explorationRect;

  /* -------------------------------------------- */
  /*  Layer Initialization                        */
  /* -------------------------------------------- */

  /**
   * Initialize all Token vision sources which are present on this layer
   */
  initializeSources() {
    canvas.effects.toggleMaskingFilters(false); // Deactivate vision masking before destroying textures
    for ( const source of canvas.effects.visionSources ) source.initialize();
    Hooks.callAll("initializeVisionSources", canvas.effects.visionSources);
  }

  /* -------------------------------------------- */

  /**
   * Initialize the vision mode.
   */
  initializeVisionMode() {
    this.visionModeData.source = this.#getSingleVisionSource();
    this.#configureLightingVisibility();
    this.#updateLightingPostProcessing();
    this.#updateTintPostProcessing();
    Hooks.callAll("initializeVisionMode", this);
  }

  /* -------------------------------------------- */

  /**
   * Identify whether there is one singular vision source active (excluding previews).
   * @returns {VisionSource|null}                         A singular source, or null
   */
  #getSingleVisionSource() {
    return canvas.effects.visionSources.filter(s => s.active).sort((a, b) =>
      (a.isPreview - b.isPreview)
      || (a.isBlinded - b.isBlinded)
      || (b.visionMode.perceivesLight - a.visionMode.perceivesLight)
    ).at(0) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Configure the visibility of individual lighting channels based on the currently active vision source(s).
   */
  #configureLightingVisibility() {
    const vs = this.visionModeData.source;
    const vm = vs?.visionMode;
    const lv = this.lightingVisibility;
    const lvs = VisionMode.LIGHTING_VISIBILITY;
    Object.assign(lv, {
      background: CanvasVisibility.#requireBackgroundShader(vm),
      illumination: vm?.lighting.illumination.visibility ?? lvs.ENABLED,
      coloration: vm?.lighting.coloration.visibility ?? lvs.ENABLED,
      darkness: vm?.lighting.darkness.visibility ?? lvs.ENABLED
    });
    lv.any = (lv.background + lv.illumination + lv.coloration + lv.darkness) > VisionMode.LIGHTING_VISIBILITY.DISABLED;
  }

  /* -------------------------------------------- */

  /**
   * Update the lighting according to vision mode options.
   */
  #updateLightingPostProcessing() {
    // Check whether lighting configuration has changed
    const lightingOptions = this.visionModeData.source?.visionMode.lighting || {};
    const diffOpt = foundry.utils.diffObject(this.visionModeData.activeLightingOptions, lightingOptions);
    this.visionModeData.activeLightingOptions = lightingOptions;
    if ( foundry.utils.isEmpty(lightingOptions) ) canvas.effects.resetPostProcessingFilters();
    if ( foundry.utils.isEmpty(diffOpt) ) return;

    // Update post-processing filters and refresh lighting
    const modes = CONFIG.Canvas.visualEffectsMaskingFilter.FILTER_MODES;
    canvas.effects.resetPostProcessingFilters();
    for ( const layer of ["background", "illumination", "coloration"] ) {
      if ( layer in lightingOptions ) {
        const options = lightingOptions[layer];
        const filterMode = modes[layer.toUpperCase()];
        canvas.effects.activatePostProcessingFilters(filterMode, options.postProcessingModes, options.uniforms);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Refresh the tint of the post processing filters.
   */
  #updateTintPostProcessing() {
    // Update tint
    const activeOptions = this.visionModeData.activeLightingOptions;
    const singleSource = this.visionModeData.source;
    const color = singleSource?.visionModeOverrides.colorRGB;
    for ( const f of canvas.effects.visualEffectsMaskingFilters ) {
      const defaultTint = f.constructor.defaultUniforms.tint;
      const tintedLayer = activeOptions[f.uniforms.mode]?.uniforms?.tint;
      f.uniforms.tint = tintedLayer ? (color ?? (tintedLayer ?? defaultTint)) : defaultTint;
    }
  }

  /* -------------------------------------------- */

  /**
   * Give the visibility requirement of the lighting background shader.
   * @param {VisionMode} visionMode             The single Vision Mode active at the moment (if any).
   * @returns {VisionMode.LIGHTING_VISIBILITY}
   */
  static #requireBackgroundShader(visionMode) {
    // Do we need to force lighting background shader? Force when :
    // - Multiple vision modes are active with a mix of preferred and non preferred visions
    // - Or when some have background shader required
    const lvs = VisionMode.LIGHTING_VISIBILITY;
    let preferred = false;
    let nonPreferred = false;
    for ( const vs of canvas.effects.visionSources ) {
      if ( !vs.active ) continue;
      const vm = vs.visionMode;
      if ( vm.lighting.background.visibility === lvs.REQUIRED ) return lvs.REQUIRED;
      if ( vm.vision.preferred ) preferred = true;
      else nonPreferred = true;
    }
    if ( preferred && nonPreferred ) return lvs.REQUIRED;
    return visionMode?.lighting.background.visibility ?? lvs.ENABLED;
  }

  /* -------------------------------------------- */
  /*  Layer Rendering                             */
  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.#configureVisibilityTexture();

    // Initialize fog
    await canvas.fog.initialize();

    // Create the vision container and attach it to the CanvasVisionMask cached container
    this.vision = this.#createVision();
    canvas.masks.vision.attachVision(this.vision);
    this.#cacheLights(true);

    // Exploration container
    this.explored = this.addChild(this.#createExploration());

    // Loading the fog overlay
    await this.#drawVisibilityOverlay();

    // Apply the visibility filter with a normal blend
    this.filter = CONFIG.Canvas.visibilityFilter.create({
      unexploredColor: canvas.colors.fogUnexplored.rgb,
      exploredColor: canvas.colors.fogExplored.rgb,
      backgroundColor: canvas.colors.background.rgb,
      visionTexture: canvas.masks.vision.renderTexture,
      primaryTexture: canvas.primary.renderTexture,
      overlayTexture: this.visibilityOverlay?.texture ?? null,
      dimensions: this.#visibilityOverlayDimensions,
      hasOverlayTexture: !!this.visibilityOverlay?.texture.valid
    }, canvas.visibilityOptions);
    this.filter.blendMode = PIXI.BLEND_MODES.NORMAL;
    this.filters = [this.filter];
    this.filterArea = canvas.app.screen;

    // Add the visibility filter to the canvas blur filter list
    canvas.addBlurFilter(this.filter);
    this.visible = false;
    this.#initialized = true;
  }

  /* -------------------------------------------- */

  /**
   * Create the exploration container with its exploration sprite.
   * @returns {PIXI.Container}   The newly created exploration container.
   */
  #createExploration() {
    const dims = canvas.dimensions;
    const explored = new PIXI.Container();
    const explorationSprite = explored.addChild(canvas.fog.sprite);
    const exr = this.#explorationRect;

    // Check if custom exploration dimensions are required
    if ( exr ) {
      explorationSprite.position.set(exr.x, exr.y);
      explorationSprite.width = exr.width;
      explorationSprite.height = exr.height;
    }

    // Otherwise, use the standard behavior
    else {
      explorationSprite.position.set(dims.sceneX, dims.sceneY);
      explorationSprite.width = this.#textureConfiguration.width;
      explorationSprite.height = this.#textureConfiguration.height;
    }
    return explored;
  }

  /* -------------------------------------------- */

  /**
   * Create the vision container and all its children.
   * @returns {PIXI.Container} The created vision container.
   */
  #createVision() {
    const dims = canvas.dimensions;
    const vision = new PIXI.Container();

    // Adding a void filter necessary when commiting fog on a texture for dynamic illumination
    vision.containmentFilter = VoidFilter.create();
    vision.containmentFilter.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    vision.containmentFilter.enabled = false; // Disabled by default, used only when writing on textures
    vision.filters = [vision.containmentFilter];

    // Areas visible because of light sources and light perception
    vision.light = vision.addChild(new PIXI.Container());

    // The global light container, which hold darkness level meshes for dynamic illumination
    vision.light.global = vision.light.addChild(new PIXI.Container());
    vision.light.global.source = vision.light.global.addChild(new PIXI.LegacyGraphics());
    vision.light.global.meshes = vision.light.global.addChild(new PIXI.Container());
    vision.light.global.source.blendMode = PIXI.BLEND_MODES.MAX_COLOR;

    // The light sources
    vision.light.sources = vision.light.addChild(new PIXI.LegacyGraphics());
    vision.light.sources.blendMode = PIXI.BLEND_MODES.MAX_COLOR;

    // Preview container, which is not cached
    vision.light.preview = vision.light.addChild(new PIXI.LegacyGraphics());
    vision.light.preview.blendMode = PIXI.BLEND_MODES.MAX_COLOR;

    // The cached light to avoid too many geometry drawings
    vision.light.cached = vision.light.addChild(new SpriteMesh(Canvas.getRenderTexture({
      textureConfiguration: this.textureConfiguration
    })));
    vision.light.cached.position.set(dims.sceneX, dims.sceneY);
    vision.light.cached.blendMode = PIXI.BLEND_MODES.MAX_COLOR;

    // The masked area
    vision.light.mask = vision.light.addChild(new PIXI.LegacyGraphics());
    vision.light.mask.preview = vision.light.mask.addChild(new PIXI.LegacyGraphics());

    // Areas visible because of FOV of vision sources
    vision.sight = vision.addChild(new PIXI.LegacyGraphics());
    vision.sight.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    vision.sight.preview = vision.sight.addChild(new PIXI.LegacyGraphics());
    vision.sight.preview.blendMode = PIXI.BLEND_MODES.MAX_COLOR;

    // Eraser for darkness sources
    vision.darkness = vision.addChild(new PIXI.LegacyGraphics());
    vision.darkness.blendMode = PIXI.BLEND_MODES.ERASE;

    /** @deprecated since v12 */
    Object.defineProperty(vision, "base", {
      get() {
        const msg = "CanvasVisibility#vision#base is deprecated in favor of CanvasVisibility#vision#light#preview.";
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this.fov.preview;
      }
    });
    /** @deprecated since v12 */
    Object.defineProperty(vision, "fov", {
      get() {
        const msg = "CanvasVisibility#vision#fov is deprecated in favor of CanvasVisibility#vision#light.";
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this.light;
      }
    });
    /** @deprecated since v12 */
    Object.defineProperty(vision, "los", {
      get() {
        const msg = "CanvasVisibility#vision#los is deprecated in favor of CanvasVisibility#vision#light#mask.";
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this.light.mask;
      }
    });
    /** @deprecated since v12 */
    Object.defineProperty(vision.light, "lights", {
      get: () => {
        const msg = "CanvasVisibility#vision#fov#lights is deprecated without replacement.";
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this.#cachedLights;
      }
    });
    /** @deprecated since v12 */
    Object.defineProperty(vision.light, "lightsSprite", {
      get() {
        const msg = "CanvasVisibility#vision#fov#lightsSprite is deprecated in favor of CanvasVisibility#vision#light#cached.";
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this.cached;
      }
    });
    /** @deprecated since v12 */
    Object.defineProperty(vision.light, "tokens", {
      get() {
        const msg = "CanvasVisibility#vision#tokens is deprecated in favor of CanvasVisibility#vision#light.";
        foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
        return this;
      }
    });
    return vision;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    canvas.masks.vision.detachVision();
    this.#cachedLightSourceStates.clear();
    await canvas.fog.clear();

    // Performs deep cleaning of the detached vision container
    this.vision.destroy({children: true, texture: true, baseTexture: true});
    this.vision = undefined;

    canvas.effects.visionSources.clear();
    this.#initialized = false;
    return super._tearDown(options);
  }

  /* -------------------------------------------- */

  /**
   * Update the display of the sight layer.
   * Organize sources into rendering queues and draw lighting containers for each source
   */
  refresh() {
    if ( !this.initialized ) return;

    // Refresh visibility
    if ( this.tokenVision ) {
      this.refreshVisibility();
      this.visible = canvas.effects.visionSources.some(s => s.active) || !game.user.isGM;
    }
    else this.visible = false;

    // Update visibility of objects
    this.restrictVisibility();
  }

  /* -------------------------------------------- */

  /**
   * Update vision (and fog if necessary)
   */
  refreshVisibility() {
    canvas.masks.vision.renderDirty = true;
    if ( !this.vision ) return;
    const vision = this.vision;

    // Begin fills
    const fillColor = 0xFF0000;
    this.#cachedLights.beginFill(fillColor);
    vision.light.sources.clear().beginFill(fillColor);
    vision.light.preview.clear().beginFill(fillColor);
    vision.light.global.source.clear().beginFill(fillColor);
    vision.light.mask.clear().beginFill();
    vision.light.mask.preview.clear().beginFill();
    vision.sight.clear().beginFill(fillColor);
    vision.sight.preview.clear().beginFill(fillColor);
    vision.darkness.clear().beginFill(fillColor);

    // Checking if the lights cache needs a full redraw
    const redrawCache = this.#checkCachedLightSources();
    if ( redrawCache ) this.#cachedLightSourceStates.clear();

    // A flag to know if the lights cache render texture need to be refreshed
    let refreshCache = redrawCache;

    // A flag to know if fog need to be refreshed.
    let commitFog = false;

    // Iterating over each active light source
    for ( const [sourceId, lightSource] of canvas.effects.lightSources.entries() ) {
      // Ignoring inactive sources or global light (which is rendered using the global light mesh)
      if ( !lightSource.hasActiveLayer || (lightSource instanceof foundry.canvas.sources.GlobalLightSource) ) continue;

      // Is the light source providing vision?
      if ( lightSource.data.vision ) {
        if ( lightSource.isPreview ) vision.light.mask.preview.drawShape(lightSource.shape);
        else {
          vision.light.mask.drawShape(lightSource.shape);
          commitFog = true;
        }
      }

      // Update the cached state. Skip if already cached.
      const isCached = this.#shouldCacheLight(lightSource);
      if ( isCached ) {
        if ( this.#cachedLightSourceStates.has(sourceId) ) continue;
        this.#cachedLightSourceStates.set(sourceId, lightSource.updateId);
        refreshCache = true;
      }

      // Draw the light source
      if ( isCached ) this.#cachedLights.drawShape(lightSource.shape);
      else if ( lightSource.isPreview ) vision.light.preview.drawShape(lightSource.shape);
      else vision.light.sources.drawShape(lightSource.shape);
    }

    // Refresh the light source cache if necessary.
    // Note: With a full redraw, we need to refresh the texture cache, even if no elements are present
    if ( refreshCache ) this.#cacheLights(redrawCache);

    // Refresh global/dynamic illumination with global source and illumination meshes
    this.#refreshDynamicIllumination();

    // Iterating over each active vision source
    for ( const visionSource of canvas.effects.visionSources ) {
      if ( !visionSource.hasActiveLayer ) continue;
      const blinded = visionSource.isBlinded;

      // Draw vision FOV
      if ( (visionSource.radius > 0) && !blinded && !visionSource.isPreview ) {
        vision.sight.drawShape(visionSource.shape);
        commitFog = true;
      }
      else vision.sight.preview.drawShape(visionSource.shape);

      // Draw light perception
      if ( (visionSource.lightRadius > 0) && !blinded && !visionSource.isPreview ) {
        vision.light.mask.drawShape(visionSource.light);
        commitFog = true;
      }
      else vision.light.mask.preview.drawShape(visionSource.light);
    }

    // Call visibility refresh hook
    Hooks.callAll("visibilityRefresh", this);

    // End fills
    vision.light.sources.endFill();
    vision.light.preview.endFill();
    vision.light.global.source.endFill();
    vision.light.mask.endFill();
    vision.light.mask.preview.endFill();
    vision.sight.endFill();
    vision.sight.preview.endFill();
    vision.darkness.endFill();

    // Update fog of war texture (if fow is activated)
    if ( commitFog ) canvas.fog.commit();
  }

  /* -------------------------------------------- */

  /**
   * Reset the exploration container with the fog sprite
   */
  resetExploration() {
    if ( !this.explored ) return;
    this.explored.destroy();
    this.explored = this.addChild(this.#createExploration());
  }

  /* -------------------------------------------- */

  /**
   * Refresh the dynamic illumination with darkness level meshes and global light.
   * Tell if a fence filter is needed when vision is rendered into a texture.
   */
  #refreshDynamicIllumination() {
    // Reset filter containment
    this.#needsContainment = false;

    // Setting global light source container visibility
    const globalLightSource = canvas.environment.globalLightSource;
    const v = this.vision.light.global.visible = globalLightSource.active;
    if ( !v ) return;
    const {min, max} = globalLightSource.data.darkness;

    // Draw the global source if necessary
    const darknessLevel = canvas.environment.darknessLevel;
    if ( (darknessLevel >= min) && (darknessLevel <= max) ) {
      this.vision.light.global.source.drawShape(globalLightSource.shape);
    }

    // Then draw dynamic illumination meshes
    const illuminationMeshes = this.vision.light.global.meshes.children;
    for ( const mesh of illuminationMeshes ) {
      const darknessLevel = mesh.shader.darknessLevel;
      if ( (darknessLevel < min) || (darknessLevel > max)) {
        mesh.blendMode = PIXI.BLEND_MODES.ERASE;
        this.#needsContainment = true;
      }
      else mesh.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    }
  }

  /* -------------------------------------------- */

  /**
   * Returns true if the light source should be cached.
   * @param {LightSource} lightSource    The light source
   * @returns {boolean}
   */
  #shouldCacheLight(lightSource) {
    return !(lightSource.object instanceof Token) && !lightSource.isPreview;
  }

  /* -------------------------------------------- */

  /**
   * Check if the cached light sources need to be fully redrawn.
   * @returns {boolean}    True if a full redraw is necessary.
   */
  #checkCachedLightSources() {
    for ( const [sourceId, updateId] of this.#cachedLightSourceStates ) {
      const lightSource = canvas.effects.lightSources.get(sourceId);
      if ( !lightSource || !lightSource.active || !this.#shouldCacheLight(lightSource)
        || (updateId !== lightSource.updateId) ) return true;
    }
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Render `this.#cachedLights` into `this.vision.light.cached.texture`.
   * Note: A full cache redraw needs the texture to be cleared.
   * @param {boolean} clearTexture       If the texture need to be cleared before rendering.
   */
  #cacheLights(clearTexture) {
    const dims = canvas.dimensions;
    this.#renderTransform.tx = -dims.sceneX;
    this.#renderTransform.ty = -dims.sceneY;
    this.#cachedLights.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    canvas.app.renderer.render(this.#cachedLights, {
      renderTexture: this.vision.light.cached.texture,
      clear: clearTexture,
      transform: this.#renderTransform
    });
    this.#cachedLights.clear();
  }

  /* -------------------------------------------- */
  /*  Visibility Testing                          */
  /* -------------------------------------------- */

  /**
   * Restrict the visibility of certain canvas assets (like Tokens or DoorControls) based on the visibility polygon
   * These assets should only be displayed if they are visible given the current player's field of view
   */
  restrictVisibility() {
    // Activate or deactivate visual effects vision masking
    canvas.effects.toggleMaskingFilters(this.visible);

    // Tokens & Notes
    const flags = {refreshVisibility: true};
    for ( const token of canvas.tokens.placeables ) token.renderFlags.set(flags);
    for ( const note of canvas.notes.placeables ) note.renderFlags.set(flags);

    // Door Icons
    for ( const door of canvas.controls.doors.children ) door.visible = door.isVisible;

    Hooks.callAll("sightRefresh", this);
  }

  /* -------------------------------------------- */

  /**
   * @typedef {Object} CanvasVisibilityTestConfig
   * @property {object|null} object              The target object
   * @property {CanvasVisibilityTest[]} tests    An array of visibility tests
   */

  /**
   * @typedef {Object} CanvasVisibilityTest
   * @property {Point} point
   * @property {number} elevation
   * @property {Map<VisionSource, boolean>} los
   */

  /**
   * Test whether a target point on the Canvas is visible based on the current vision and LOS polygons.
   * @param {Point} point                     The point in space to test, an object with coordinates x and y.
   * @param {object} [options]                Additional options which modify visibility testing.
   * @param {number} [options.tolerance=2]    A numeric radial offset which allows for a non-exact match.
   *                                          For example, if tolerance is 2 then the test will pass if the point
   *                                          is within 2px of a vision polygon.
   * @param {object|null} [options.object]    An optional reference to the object whose visibility is being tested
   * @returns {boolean}                       Whether the point is currently visible.
   */
  testVisibility(point, options={}) {

    // If no vision sources are present, the visibility is dependant of the type of user
    if ( !canvas.effects.visionSources.some(s => s.active) ) return game.user.isGM;

    // Prepare an array of test points depending on the requested tolerance
    const object = options.object ?? null;
    const config = this._createVisibilityTestConfig(point, options);

    // First test basic detection for light sources which specifically provide vision
    for ( const lightSource of canvas.effects.lightSources ) {
      if ( !lightSource.data.vision || !lightSource.active ) continue;
      const result = lightSource.testVisibility(config);
      if ( result === true ) return true;
    }

    // Get scene rect to test that some points are not detected into the padding
    const sr = canvas.dimensions.sceneRect;
    const inBuffer = !sr.contains(point.x, point.y);

    // Skip sources that are not both inside the scene or both inside the buffer
    const activeVisionSources = canvas.effects.visionSources.filter(s => s.active
      && (inBuffer !== sr.contains(s.x, s.y)));
    const modes = CONFIG.Canvas.detectionModes;

    // Second test Basic Sight and Light Perception tests for vision sources
    for ( const visionSource of activeVisionSources ) {
      if ( visionSource.isBlinded ) continue;
      const token = visionSource.object.document;
      const basicMode = token.detectionModes.find(m => m.id === "basicSight");
      if ( basicMode ) {
        const result = modes.basicSight.testVisibility(visionSource, basicMode, config);
        if ( result === true ) return true;
      }
      const lightMode = token.detectionModes.find(m => m.id === "lightPerception");
      if ( lightMode ) {
        const result = modes.lightPerception.testVisibility(visionSource, lightMode, config);
        if ( result === true ) return true;
      }
    }

    // Special detection modes can only detect tokens
    if ( !(object instanceof Token) ) return false;

    // Lastly test special detection modes for vision sources
    for ( const visionSource of activeVisionSources ) {
      const token = visionSource.object.document;
      for ( const mode of token.detectionModes ) {
        if ( (mode.id === "basicSight") || (mode.id === "lightPerception") ) continue;
        const dm = modes[mode.id];
        const result = dm?.testVisibility(visionSource, mode, config);
        if ( result === true ) {
          object.detectionFilter = dm.constructor.getDetectionFilter();
          return true;
        }
      }
    }
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Create the visibility test config.
   * @param {Point} point                     The point in space to test, an object with coordinates x and y.
   * @param {object} [options]                Additional options which modify visibility testing.
   * @param {number} [options.tolerance=2]    A numeric radial offset which allows for a non-exact match.
   *                                          For example, if tolerance is 2 then the test will pass if the point
   *                                          is within 2px of a vision polygon.
   * @param {object|null} [options.object]    An optional reference to the object whose visibility is being tested
   * @returns {CanvasVisibilityTestConfig}
   * @internal
   */
  _createVisibilityTestConfig(point, {tolerance=2, object=null}={}) {
    const t = tolerance;
    const offsets = t > 0 ? [[0, 0], [-t, -t], [-t, t], [t, t], [t, -t], [-t, 0], [t, 0], [0, -t], [0, t]] : [[0, 0]];
    const elevation = object instanceof Token ? object.document.elevation : 0;
    return {
      object,
      tests: offsets.map(o => ({
        point: {x: point.x + o[0], y: point.y + o[1]},
        elevation,
        los: new Map()
      }))
    };
  }

  /* -------------------------------------------- */
  /*  Visibility Overlay and Texture management   */
  /* -------------------------------------------- */

  /**
   * Load the scene fog overlay if provided and attach the fog overlay sprite to this layer.
   */
  async #drawVisibilityOverlay() {
    this.visibilityOverlay = undefined;
    this.#visibilityOverlayDimensions = [];
    const overlaySrc = canvas.sceneTextures.fogOverlay ?? canvas.scene.fog.overlay;
    const overlayTexture = overlaySrc instanceof PIXI.Texture ? overlaySrc : getTexture(overlaySrc);
    if ( !overlayTexture ) return;

    // Creating the sprite and updating its base texture with repeating wrap mode
    const fo = this.visibilityOverlay = new PIXI.Sprite(overlayTexture);

    // Set dimensions and position according to overlay <-> scene foreground dimensions
    const bkg = canvas.primary.background;
    const baseTex = overlayTexture.baseTexture;
    if ( bkg && ((fo.width !== bkg.width) || (fo.height !== bkg.height)) ) {
      // Set to the size of the scene dimensions
      fo.width = canvas.scene.dimensions.width;
      fo.height = canvas.scene.dimensions.height;
      fo.position.set(0, 0);
      // Activate repeat wrap mode for this base texture (to allow tiling)
      baseTex.wrapMode = PIXI.WRAP_MODES.REPEAT;
    }
    else {
      // Set the same position and size as the scene primary background
      fo.width = bkg.width;
      fo.height = bkg.height;
      fo.position.set(bkg.x, bkg.y);
    }

    // The overlay is added to this canvas container to update its transforms only
    fo.renderable = false;
    this.addChild(this.visibilityOverlay);

    // Manage video playback
    const video = game.video.getVideoSource(overlayTexture);
    if ( video ) {
      const playOptions = {volume: 0};
      game.video.play(video, playOptions);
    }

    // Passing overlay and base texture width and height for shader tiling calculations
    this.#visibilityOverlayDimensions = [fo.width, fo.height, baseTex.width, baseTex.height];
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} VisibilityTextureConfiguration
   * @property {number} resolution
   * @property {number} width
   * @property {number} height
   * @property {number} mipmap
   * @property {number} scaleMode
   * @property {number} multisample
   */

  /**
   * Configure the fog texture will all required options.
   * Choose an adaptive fog rendering resolution which downscales the saved fog textures for larger dimension Scenes.
   * It is important that the width and height of the fog texture is evenly divisible by the downscaling resolution.
   * @returns {VisibilityTextureConfiguration}
   * @private
   */
  #configureVisibilityTexture() {
    const dims = canvas.dimensions;
    let width = dims.sceneWidth;
    let height = dims.sceneHeight;
    const maxSize = CanvasVisibility.#MAXIMUM_VISIBILITY_TEXTURE_SIZE;

    // Adapt the fog texture resolution relative to some maximum size, and ensure that multiplying the scene dimensions
    // by the resolution results in an integer number in order to avoid fog drift.
    let resolution = 1.0;
    if ( (width >= height) && (width > maxSize) ) {
      resolution = maxSize / width;
      height = Math.ceil(height * resolution) / resolution;
    } else if ( height > maxSize ) {
      resolution = maxSize / height;
      width = Math.ceil(width * resolution) / resolution;
    }

    // Determine the fog texture options
    return this.#textureConfiguration = {
      resolution,
      width,
      height,
      mipmap: PIXI.MIPMAP_MODES.OFF,
      multisample: PIXI.MSAA_QUALITY.NONE,
      scaleMode: PIXI.SCALE_MODES.LINEAR,
      alphaMode: PIXI.ALPHA_MODES.NPM,
      format: PIXI.FORMATS.RED
    };
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  get fogOverlay() {
    const msg = "fogOverlay is deprecated in favor of visibilityOverlay";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return this.visibilityOverlay;
  }
}

/**
 * A CanvasLayer for displaying visual effects like weather, transitions, flashes, or more.
 */
class WeatherEffects extends FullCanvasObjectMixin(CanvasLayer) {
  constructor() {
    super();
    this.#initializeFilters();
    this.mask = canvas.masks.scene;
    this.sortableChildren = true;
    this.eventMode = "none";
  }

  /**
   * The container in which effects are added.
   * @type {PIXI.Container}
   */
  weatherEffects;

  /* -------------------------------------------- */

  /**
   * The container in which suppression meshed are added.
   * @type {PIXI.Container}
   */
  suppression;

  /* -------------------------------------------- */

  /**
   * Initialize the inverse occlusion and the void filters.
   */
  #initializeFilters() {
    this.#suppressionFilter = VoidFilter.create();
    this.occlusionFilter = WeatherOcclusionMaskFilter.create({
      occlusionTexture: canvas.masks.depth.renderTexture
    });
    this.#suppressionFilter.enabled = this.occlusionFilter.enabled = false;
    // FIXME: this does not produce correct results for weather effects that are configured
    // with the occlusion filter disabled and use a different blend mode than SCREEN
    this.#suppressionFilter.blendMode = PIXI.BLEND_MODES.SCREEN;
    this.occlusionFilter.elevation = this.#elevation;
    this.filterArea = canvas.app.renderer.screen;
    this.filters = [this.occlusionFilter, this.#suppressionFilter];
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {name: "effects"});
  }

  /* -------------------------------------------- */

  /**
   * Array of weather effects linked to this weather container.
   * @type {Map<string,(ParticleEffect|WeatherShaderEffect)[]>}
   */
  effects = new Map();

  /**
   * @typedef {Object} WeatherTerrainMaskConfiguration
   * @property {boolean} enabled                          Enable or disable this mask.
   * @property {number[]} channelWeights                  An RGBA array of channel weights applied to the mask texture.
   * @property {boolean} [reverse=false]                  If the mask should be reversed.
   * @property {PIXI.Texture|PIXI.RenderTexture} texture  A texture which defines the mask region.
   */

  /**
   * A default configuration of the terrain mask that is automatically applied to any shader-based weather effects.
   * This configuration is automatically passed to WeatherShaderEffect#configureTerrainMask upon construction.
   * @type {WeatherTerrainMaskConfiguration}
   */
  terrainMaskConfig;

  /**
   * @typedef {Object} WeatherOcclusionMaskConfiguration
   * @property {boolean} enabled                          Enable or disable this mask.
   * @property {number[]} channelWeights                  An RGBA array of channel weights applied to the mask texture.
   * @property {boolean} [reverse=false]                  If the mask should be reversed.
   * @property {PIXI.Texture|PIXI.RenderTexture} texture  A texture which defines the mask region.
   */

  /**
   * A default configuration of the terrain mask that is automatically applied to any shader-based weather effects.
   * This configuration is automatically passed to WeatherShaderEffect#configureTerrainMask upon construction.
   * @type {WeatherOcclusionMaskConfiguration}
   */
  occlusionMaskConfig;

  /**
   * The inverse occlusion mask filter bound to this container.
   * @type {WeatherOcclusionMaskFilter}
   */
  occlusionFilter;

  /**
   * The filter that is needed for suppression if the occlusion filter isn't enabled.
   * @type {VoidFilter}
   */
  #suppressionFilter;

  /* -------------------------------------------- */

  /**
   * The elevation of this object.
   * @type {number}
   * @default Infinity
   */
  get elevation() {
    return this.#elevation;
  }

  set elevation(value) {
    if ( (typeof value !== "number") || Number.isNaN(value) ) {
      throw new Error("WeatherEffects#elevation must be a numeric value.");
    }
    if ( value === this.#elevation ) return;
    this.#elevation = value;
    if ( this.parent ) this.parent.sortDirty = true;
  }

  #elevation = Infinity;

  /* -------------------------------------------- */

  /**
   * A key which resolves ties amongst objects at the same elevation of different layers.
   * @type {number}
   * @default PrimaryCanvasGroup.SORT_LAYERS.WEATHER
   */
  get sortLayer() {
    return this.#sortLayer;
  }

  set sortLayer(value) {
    if ( (typeof value !== "number") || Number.isNaN(value) ) {
      throw new Error("WeatherEffects#sortLayer must be a numeric value.");
    }
    if ( value === this.#sortLayer ) return;
    this.#sortLayer = value;
    if ( this.parent ) this.parent.sortDirty = true;
  }

  #sortLayer = PrimaryCanvasGroup.SORT_LAYERS.WEATHER;

  /* -------------------------------------------- */

  /**
   * A key which resolves ties amongst objects at the same elevation within the same layer.
   * @type {number}
   * @default 0
   */
  get sort() {
    return this.#sort;
  }

  set sort(value) {
    if ( (typeof value !== "number") || Number.isNaN(value) ) {
      throw new Error("WeatherEffects#sort must be a numeric value.");
    }
    if ( value === this.#sort ) return;
    this.#sort = value;
    if ( this.parent ) this.parent.sortDirty = true;
  }

  #sort = 0;

  /* -------------------------------------------- */

  /**
   * A key which resolves ties amongst objects at the same elevation within the same layer and same sort.
   * @type {number}
   * @default 0
   */
  get zIndex() {
    return this._zIndex;
  }

  set zIndex(value) {
    if ( (typeof value !== "number") || Number.isNaN(value) ) {
      throw new Error("WeatherEffects#zIndex must be a numeric value.");
    }
    if ( value === this._zIndex ) return;
    this._zIndex = value;
    if ( this.parent ) this.parent.sortDirty = true;
  }

  /* -------------------------------------------- */
  /*  Weather Effect Rendering                    */
  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    const effect = CONFIG.weatherEffects[canvas.scene.weather];
    this.weatherEffects = this.addChild(new PIXI.Container());
    this.suppression = this.addChild(new PIXI.Container());
    for ( const event of ["childAdded", "childRemoved"] ) {
      this.suppression.on(event, () => {
        this.#suppressionFilter.enabled = !this.occlusionFilter.enabled && !!this.suppression.children.length;
      });
    }
    this.initializeEffects(effect);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    this.clearEffects();
    return super._tearDown(options);
  }

  /* -------------------------------------------- */
  /*  Weather Effect Management                   */
  /* -------------------------------------------- */

  /**
   * Initialize the weather container from a weather config object.
   * @param {object} [weatherEffectsConfig]        Weather config object (or null/undefined to clear the container).
   */
  initializeEffects(weatherEffectsConfig) {
    this.#destroyEffects();
    Hooks.callAll("initializeWeatherEffects", this, weatherEffectsConfig);
    this.#constructEffects(weatherEffectsConfig);
  }

  /* -------------------------------------------- */

  /**
   * Clear the weather container.
   */
  clearEffects() {
    this.initializeEffects(null);
  }

  /* -------------------------------------------- */

  /**
   * Destroy all effects associated with this weather container.
   */
  #destroyEffects() {
    if ( this.effects.size === 0 ) return;
    for ( const effect of this.effects.values() ) effect.destroy();
    this.effects.clear();
  }

  /* -------------------------------------------- */

  /**
   * Construct effects according to the weather effects config object.
   * @param {object} [weatherEffectsConfig]        Weather config object (or null/undefined to clear the container).
   */
  #constructEffects(weatherEffectsConfig) {
    if ( !weatherEffectsConfig ) {
      this.#suppressionFilter.enabled = this.occlusionFilter.enabled = false;
      return;
    }
    const effects = weatherEffectsConfig.effects;
    let zIndex = 0;

    // Enable a layer-wide occlusion filter unless it is explicitly disabled by the effect configuration
    const useOcclusionFilter = weatherEffectsConfig.filter?.enabled !== false;
    if ( useOcclusionFilter ) {
      WeatherEffects.configureOcclusionMask(this.occlusionFilter, this.occlusionMaskConfig || {enabled: true});
      if ( this.terrainMaskConfig ) WeatherEffects.configureTerrainMask(this.occlusionFilter, this.terrainMaskConfig);
      this.occlusionFilter.blendMode = weatherEffectsConfig.filter?.blendMode ?? PIXI.BLEND_MODES.NORMAL;
      this.occlusionFilter.enabled = true;
      this.#suppressionFilter.enabled = false;
    }
    else {
      this.#suppressionFilter.enabled = !!this.suppression.children.length;
    }

    // Create each effect
    for ( const effect of effects ) {
      const requiredPerformanceLevel = Number.isNumeric(effect.performanceLevel) ? effect.performanceLevel : 0;
      if ( canvas.performance.mode < requiredPerformanceLevel ) {
        console.debug(`Skipping weather effect ${effect.id}. The client performance level ${canvas.performance.mode}`
          + ` is less than the required performance mode ${requiredPerformanceLevel} for the effect`);
        continue;
      }

      // Construct the effect container
      let ec;
      try {
        ec = new effect.effectClass(effect.config, effect.shaderClass);
      } catch(err) {
        err.message = `Failed to construct weather effect: ${err.message}`;
        console.error(err);
        continue;
      }

      // Configure effect container
      ec.zIndex = effect.zIndex ?? zIndex++;
      ec.blendMode = effect.blendMode ?? PIXI.BLEND_MODES.NORMAL;

      // Apply effect-level occlusion and terrain masking only if we are not using a layer-wide filter
      if ( effect.shaderClass && !useOcclusionFilter ) {
        WeatherEffects.configureOcclusionMask(ec.shader, this.occlusionMaskConfig || {enabled: true});
        if ( this.terrainMaskConfig ) WeatherEffects.configureTerrainMask(ec.shader, this.terrainMaskConfig);
      }

      // Add to the layer, register the effect, and begin play
      this.weatherEffects.addChild(ec);
      this.effects.set(effect.id, ec);
      ec.play();
    }
  }

  /* -------------------------------------------- */

  /**
   * Set the occlusion uniforms for this weather shader.
   * @param {PIXI.Shader} context                       The shader context
   * @param {WeatherOcclusionMaskConfiguration} config  Occlusion masking options
   * @protected
   */
  static configureOcclusionMask(context, {enabled=false, channelWeights=[0, 0, 1, 0], reverse=false, texture}={}) {
    if ( !(context instanceof PIXI.Shader) ) return;
    const uniforms = context.uniforms;
    if ( texture !== undefined ) uniforms.occlusionTexture = texture;
    else uniforms.occlusionTexture ??= canvas.masks.depth.renderTexture;
    uniforms.useOcclusion = enabled;
    uniforms.occlusionWeights = channelWeights;
    uniforms.reverseOcclusion = reverse;
    if ( enabled && !uniforms.occlusionTexture ) {
      console.warn(`The occlusion configuration for the weather shader ${context.constructor.name} is enabled but`
        + " does not have a valid texture");
      uniforms.useOcclusion = false;
    }
  }

  /* -------------------------------------------- */

  /**
   * Set the terrain uniforms for this weather shader.
   * @param {PIXI.Shader} context                     The shader context
   * @param {WeatherTerrainMaskConfiguration} config  Terrain masking options
   * @protected
   */
  static configureTerrainMask(context, {enabled=false, channelWeights=[1, 0, 0, 0], reverse=false, texture}={}) {
    if ( !(context instanceof PIXI.Shader) ) return;
    const uniforms = context.uniforms;
    if ( texture !== undefined ) {
      uniforms.terrainTexture = texture;
      const terrainMatrix = new PIXI.TextureMatrix(texture);
      terrainMatrix.update();
      uniforms.terrainUvMatrix.copyFrom(terrainMatrix.mapCoord);
    }
    uniforms.useTerrain = enabled;
    uniforms.terrainWeights = channelWeights;
    uniforms.reverseTerrain = reverse;
    if ( enabled && !uniforms.terrainTexture ) {
      console.warn(`The terrain configuration for the weather shader ${context.constructor.name} is enabled but`
        + " does not have a valid texture");
      uniforms.useTerrain = false;
    }
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  get weather() {
    const msg = "The WeatherContainer at canvas.weather.weather is deprecated and combined with the layer itself.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return this;
  }
}

/**
 * A special Graphics class which handles Grid layer highlighting
 * @extends {PIXI.Graphics}
 */
class GridHighlight extends PIXI.Graphics {
  constructor(name, ...args) {
    super(...args);

    /**
     * Track the Grid Highlight name
     * @type {string}
     */
    this.name = name;

    /**
     * Track distinct positions which have already been highlighted
     * @type {Set}
     */
    this.positions = new Set();
  }

  /* -------------------------------------------- */

  /**
   * Record a position that is highlighted and return whether or not it should be rendered
   * @param {number} x    The x-coordinate to highlight
   * @param {number} y    The y-coordinate to highlight
   * @return {boolean}    Whether or not to draw the highlight for this location
   */
  highlight(x, y) {
    let key = `${x},${y}`;
    if ( this.positions.has(key) ) return false;
    this.positions.add(key);
    return true;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  clear() {
    this.positions = new Set();
    return super.clear();
  }


  /* -------------------------------------------- */

  /** @inheritdoc */
  destroy(...args) {
    delete canvas.interface.grid.highlightLayers[this.name];
    return super.destroy(...args);
  }
}

/**
 * A CanvasLayer responsible for drawing a square grid
 */
class GridLayer extends CanvasLayer {

  /**
   * The grid mesh.
   * @type {GridMesh}
   */
  mesh;

  /**
   * The Grid Highlight container
   * @type {PIXI.Container}
   */
  highlight;

  /**
   * Map named highlight layers
   * @type {Record<string, GridHighlight>}
   */
  highlightLayers = {};

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {name: "grid"});
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    // Draw the highlight layer
    this.highlightLayers = {};
    this.highlight = this.addChild(new PIXI.Container());
    this.highlight.sortableChildren = true;

    // Draw the grid
    this.mesh = this.addChild(await this._drawMesh());
    // Initialize the mesh appeareance
    this.initializeMesh(canvas.grid);
  }

  /* -------------------------------------------- */

  /**
   * Creates the grid mesh.
   * @returns {Promise<GridMesh>}
   * @protected
   */
  async _drawMesh() {
    return new GridMesh().initialize({
      type: canvas.grid.type,
      width: canvas.dimensions.width,
      height: canvas.dimensions.height,
      size: canvas.dimensions.size
    });
  }

  /* -------------------------------------------- */

  /**
   * Initialize the grid mesh appearance and configure the grid shader.
   * @param {object} options
   * @param {string} [options.style]         The grid style
   * @param {number} [options.thickness]     The grid thickness
   * @param {string} [options.color]         The grid color
   * @param {number} [options.alpha]         The grid alpha
   */
  initializeMesh({style, thickness, color, alpha}) {
    const {shaderClass, shaderOptions} = CONFIG.Canvas.gridStyles[style] ?? {};
    this.mesh.initialize({thickness, color, alpha});
    this.mesh.setShaderClass(shaderClass ?? GridShader);
    this.mesh.shader.configure(shaderOptions ?? {});
  }

  /* -------------------------------------------- */
  /*  Grid Highlighting Methods
  /* -------------------------------------------- */

  /**
   * Define a new Highlight graphic
   * @param {string} name     The name for the referenced highlight layer
   */
  addHighlightLayer(name) {
    const layer = this.highlightLayers[name];
    if ( !layer || layer._destroyed ) {
      this.highlightLayers[name] = this.highlight.addChild(new GridHighlight(name));
    }
    return this.highlightLayers[name];
  }

  /* -------------------------------------------- */

  /**
   * Clear a specific Highlight graphic
   * @param {string} name     The name for the referenced highlight layer
   */
  clearHighlightLayer(name) {
    const layer = this.highlightLayers[name];
    if ( layer ) layer.clear();
  }

  /* -------------------------------------------- */

  /**
   * Destroy a specific Highlight graphic
   * @param {string} name     The name for the referenced highlight layer
   */
  destroyHighlightLayer(name) {
    const layer = this.highlightLayers[name];
    if ( layer ) {
      this.highlight.removeChild(layer);
      layer.destroy();
    }
  }

  /* -------------------------------------------- */

  /**
   * Obtain the highlight layer graphic by name
   * @param {string} name     The name for the referenced highlight layer
   */
  getHighlightLayer(name) {
    return this.highlightLayers[name];
  }

  /* -------------------------------------------- */

  /**
   * Add highlighting for a specific grid position to a named highlight graphic
   * @param {string} name                        The name for the referenced highlight layer
   * @param {object} [options]                   Options for the grid position that should be highlighted
   * @param {number} [options.x]                 The x-coordinate of the highlighted position
   * @param {number} [options.y]                 The y-coordinate of the highlighted position
   * @param {PIXI.ColorSource} [options.color=0x33BBFF]    The fill color of the highlight
   * @param {PIXI.ColorSource|null} [options.border=null]  The border color of the highlight
   * @param {number} [options.alpha=0.25]        The opacity of the highlight
   * @param {PIXI.Polygon} [options.shape=null]  A predefined shape to highlight
   */
  highlightPosition(name, {x, y, color=0x33BBFF, border=null, alpha=0.25, shape=null}) {
    const layer = this.highlightLayers[name];
    if ( !layer ) return;
    const grid = canvas.grid;
    if ( grid.type !== CONST.GRID_TYPES.GRIDLESS ) {
      const cx = x + (grid.sizeX / 2);
      const cy = y + (grid.sizeY / 2);
      const points = grid.getShape();
      for ( const point of points ) {
        point.x += cx;
        point.y += cy;
      }
      shape = new PIXI.Polygon(points);
    } else if ( !shape ) return;
    if ( !layer.highlight(x, y) ) return;
    layer.beginFill(color, alpha);
    if ( border !== null ) layer.lineStyle(2, border, Math.min(alpha * 1.5, 1.0));
    layer.drawShape(shape).endFill();
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get type() {
    const msg = "GridLayer#type is deprecated. Use canvas.grid.type instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.grid.type;
  }

  /* -------------------------------------------- */


  /**
   * @deprecated since v12
   * @ignore
   */
  get size() {
    const msg = "GridLayer#size is deprecated. Use canvas.grid.size instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.grid.size;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get grid() {
    const msg = "GridLayer#grid is deprecated. Use canvas.grid instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.grid;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  isNeighbor(r0, c0, r1, c1) {
    const msg = "GridLayer#isNeighbor is deprecated. Use canvas.grid.testAdjacency instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.grid.testAdjacency({i: r0, j: c0}, {i: r1, j: c1});
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get w() {
    const msg = "GridLayer#w is deprecated in favor of canvas.grid.sizeX.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.grid.sizeX;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get h() {
    const msg = "GridLayer#h is deprecated in favor of canvas.grid.sizeY.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.grid.sizeY;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get isHex() {
    const msg = "GridLayer#isHex is deprecated. Use canvas.grid.isHexagonal instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.grid.isHexagonal;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  getTopLeft(x, y) {
    const msg = "GridLayer#getTopLeft is deprecated. Use canvas.grid.getTopLeftPoint instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.grid.getTopLeft(x, y);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  getCenter(x, y) {
    const msg = "GridLayer#getCenter is deprecated. Use canvas.grid.getCenterPoint instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return canvas.grid.getCenter(x, y);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  getSnappedPosition(x, y, interval=1, options={}) {
    const msg = "GridLayer#getSnappedPosition is deprecated. Use canvas.grid.getSnappedPoint instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    if ( interval === 0 ) return {x: Math.round(x), y: Math.round(y)};
    return canvas.grid.getSnappedPosition(x, y, interval, options);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  measureDistance(origin, target, options={}) {
    const msg = "GridLayer#measureDistance is deprecated. "
      + "Use canvas.grid.measurePath instead for non-Euclidean measurements.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    const ray = new Ray(origin, target);
    const segments = [{ray}];
    return canvas.grid.measureDistances(segments, options)[0];
  }
}

/**
 * The grid mesh data.
 * @typedef {object} GridMeshData
 * @property {number} type        The type of the grid (see {@link CONST.GRID_TYPES})
 * @property {number} width       The width of the grid in pixels
 * @property {number} height      The height of the grid in pixels
 * @property {number} size        The size of a grid space in pixels
 * @property {number} thickness   The thickness of the grid lines in pixels
 * @property {number} color       The color of the grid
 * @property {number} alpha       The alpha of the grid
 */

/**
 * The grid mesh, which uses the {@link GridShader} to render the grid.
 */
class GridMesh extends QuadMesh {

  /**
   * The grid mesh constructor.
   * @param {typeof GridShader} [shaderClass=GridShader]    The shader class
   */
  constructor(shaderClass=GridShader) {
    super(shaderClass);
    this.width = 0;
    this.height = 0;
    this.alpha = 0;
    this.renderable = false;
  }

  /* -------------------------------------------- */

  /**
   * The data of this mesh.
   * @type {GridMeshData}
   */
  data = {
    type: CONST.GRID_TYPES.GRIDLESS,
    width: 0,
    height: 0,
    size: 0,
    thickness: 1,
    color: 0,
    alpha: 1
  };

  /* -------------------------------------------- */

  /**
   * Initialize and update the mesh given the (partial) data.
   * @param {Partial<GridMeshData>} data    The (partial) data.
   * @returns {this}
   */
  initialize(data) {
    // Update the data
    this._initialize(data);

    // Update the width, height, and alpha
    const d = this.data;
    this.width = d.width;
    this.height = d.height;
    this.alpha = d.alpha;
    // Don't render if gridless or the thickness isn't positive positive
    this.renderable = (d.type !== CONST.GRID_TYPES.GRIDLESS) && (d.thickness > 0);

    return this;
  }

  /* -------------------------------------------- */

  /**
   * Initialize the data of this mesh given the (partial) data.
   * @param {Partial<GridMeshData>} data    The (partial) data.
   * @protected
   */
  _initialize(data) {
    const d = this.data;
    if ( data.type !== undefined ) d.type = data.type;
    if ( data.width !== undefined ) d.width = data.width;
    if ( data.height !== undefined ) d.height = data.height;
    if ( data.size !== undefined ) d.size = data.size;
    if ( data.thickness !== undefined ) d.thickness = data.thickness;
    if ( data.color !== undefined ) {
      const color = Color.from(data.color);
      d.color = color.valid ? color.valueOf() : 0;
    }
    if ( data.alpha !== undefined ) d.alpha = data.alpha;
  }
}


/**
 * The depth mask which contains a mapping of elevation. Needed to know if we must render objects according to depth.
 * Red channel: Lighting occlusion (top).
 * Green channel: Lighting occlusion (bottom).
 * Blue channel: Weather occlusion.
 * @category - Canvas
 */
class CanvasDepthMask extends CachedContainer {
  constructor(...args) {
    super(...args);
    this.#createDepth();
  }

  /**
   * Container in which roofs are rendered with depth data.
   * @type {PIXI.Container}
   */
  roofs;

  /** @override */
  static textureConfiguration = {
    scaleMode: PIXI.SCALE_MODES.NEAREST,
    format: PIXI.FORMATS.RGB,
    multisample: PIXI.MSAA_QUALITY.NONE
  };

  /** @override */
  clearColor = [0, 0, 0, 0];

  /**
   * Update the elevation-to-depth mapping?
   * @type {boolean}
   * @internal
   */
  _elevationDirty = false;

  /**
   * The elevations of the elevation-to-depth mapping.
   * Supported are up to 255 unique elevations.
   * @type {Float64Array}
   */
  #elevations = new Float64Array([-Infinity]);

  /* -------------------------------------------- */

  /**
   * Map an elevation to a value in the range [0, 1] with 8-bit precision.
   * The depth-rendered object are rendered with these values into the render texture.
   * @param {number} elevation    The elevation in distance units
   * @returns {number}            The value for this elevation in the range [0, 1] with 8-bit precision
   */
  mapElevation(elevation) {
    const E = this.#elevations;
    if ( elevation < E[0] ) return 0;
    let i = 0;
    let j = E.length - 1;
    while ( i < j ) {
      const k = (i + j + 1) >> 1;
      const e = E[k];
      if ( e <= elevation ) i = k;
      else j = k - 1;
    }
    return (i + 1) / 255;
  }

  /* -------------------------------------------- */

  /**
   * Update the elevation-to-depth mapping.
   * Needs to be called after the children have been sorted
   * and the canvas transform phase.
   * @internal
   */
  _update() {
    if ( !this._elevationDirty ) return;
    this._elevationDirty = false;
    const elevations = [];
    const children = canvas.primary.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      const child = children[i];
      if ( !child.shouldRenderDepth ) continue;
      const elevation = child.elevation;
      if ( elevation === elevations.at(-1) ) continue;
      elevations.push(elevation);
    }
    if ( !elevations.length ) elevations.push(-Infinity);
    else elevations.length = Math.min(elevations.length, 255);
    this.#elevations = new Float64Array(elevations);
  }

  /* -------------------------------------------- */

  /**
   * Initialize the depth mask with the roofs container and token graphics.
   */
  #createDepth() {
    this.roofs = this.addChild(this.#createRoofsContainer());
  }

  /* -------------------------------------------- */

  /**
   * Create the roofs container.
   * @returns {PIXI.Container}
   */
  #createRoofsContainer() {
    const c = new PIXI.Container();
    const render = renderer => {
      // Render the depth of each primary canvas object
      for ( const pco of canvas.primary.children ) {
        pco.renderDepthData?.(renderer);
      }
    };
    c.render = render.bind(c);
    return c;
  }

  /* -------------------------------------------- */

  /**
   * Clear the depth mask.
   */
  clear() {
    Canvas.clearContainer(this.roofs, false);
  }
}

/**
 * The occlusion mask which contains radial occlusion and vision occlusion from tokens.
 * Red channel: Fade occlusion.
 * Green channel: Radial occlusion.
 * Blue channel: Vision occlusion.
 * @category - Canvas
 */
class CanvasOcclusionMask extends CachedContainer {
  constructor(...args) {
    super(...args);
    this.#createOcclusion();
  }

  /** @override */
  static textureConfiguration = {
    scaleMode: PIXI.SCALE_MODES.NEAREST,
    format: PIXI.FORMATS.RGB,
    multisample: PIXI.MSAA_QUALITY.NONE
  };

  /**
   * Graphics in which token radial and vision occlusion shapes are drawn.
   * @type {PIXI.LegacyGraphics}
   */
  tokens;

  /**
   * The occludable tokens.
   * @type {Token[]}
   */
  #tokens;

  /** @override */
  clearColor = [0, 1, 1, 1];

  /** @override */
  autoRender = false;

  /* -------------------------------------------- */

  /**
   * Is vision occlusion active?
   * @type {boolean}
   */
  get vision() {
    return this.#vision;
  }

  /**
   * @type {boolean}
   */
  #vision = false;

  /**
   * The elevations of the elevation-to-depth mapping.
   * Supported are up to 255 unique elevations.
   * @type {Float64Array}
   */
  #elevations = new Float64Array([-Infinity]);

  /* -------------------------------------------- */

  /**
   * Initialize the depth mask with the roofs container and token graphics.
   */
  #createOcclusion() {
    this.alphaMode = PIXI.ALPHA_MODES.NO_PREMULTIPLIED_ALPHA;
    this.tokens = this.addChild(new PIXI.LegacyGraphics());
    this.tokens.blendMode = PIXI.BLEND_MODES.MIN_ALL;
  }

  /* -------------------------------------------- */

  /**
   * Clear the occlusion mask.
   */
  clear() {
    this.tokens.clear();
  }

  /* -------------------------------------------- */
  /*  Occlusion Management                        */
  /* -------------------------------------------- */

  /**
   * Map an elevation to a value in the range [0, 1] with 8-bit precision.
   * The radial and vision shapes are drawn with these values into the render texture.
   * @param {number} elevation    The elevation in distance units
   * @returns {number}            The value for this elevation in the range [0, 1] with 8-bit precision
   */
  mapElevation(elevation) {
    const E = this.#elevations;
    let i = 0;
    let j = E.length - 1;
    if ( elevation > E[j] ) return 1;
    while ( i < j ) {
      const k = (i + j) >> 1;
      const e = E[k];
      if ( e >= elevation ) j = k;
      else i = k + 1;
    }
    return i / 255;
  }

  /* -------------------------------------------- */

  /**
   * Update the set of occludable Tokens, redraw the occlusion mask, and update the occluded state
   * of all occludable objects.
   */
  updateOcclusion() {
    this.#tokens = canvas.tokens._getOccludableTokens();
    this._updateOcclusionMask();
    this._updateOcclusionStates();
  }

  /* -------------------------------------------- */

  /**
   * Draw occlusion shapes to the occlusion mask.
   * Fade occlusion draws to the red channel with varying intensity from [0, 1] based on elevation.
   * Radial occlusion draws to the green channel with varying intensity from [0, 1] based on elevation.
   * Vision occlusion draws to the blue channel with varying intensity from [0, 1] based on elevation.
   * @internal
   */
  _updateOcclusionMask() {
    this.#vision = false;
    this.tokens.clear();
    const elevations = [];
    for ( const token of this.#tokens.sort((a, b) => a.document.elevation - b.document.elevation) ) {
      const elevation = token.document.elevation;
      if ( elevation !== elevations.at(-1) ) elevations.push(elevation);
      const occlusionElevation = Math.min(elevations.length - 1, 255);

      // Draw vision occlusion
      if ( token.vision?.active ) {
        this.#vision = true;
        this.tokens.beginFill(0xFFFF00 | occlusionElevation).drawShape(token.vision.los).endFill();
      }

      // Draw radial occlusion (and radial into the vision channel if this token doesn't have vision)
      const origin = token.center;
      const occlusionRadius = Math.max(token.externalRadius, token.getLightRadius(token.document.occludable.radius));
      this.tokens.beginFill(0xFF0000 | (occlusionElevation << 8) | (token.vision?.active ? 0xFF : occlusionElevation))
        .drawCircle(origin.x, origin.y, occlusionRadius).endFill();
    }
    if ( !elevations.length ) elevations.push(-Infinity);
    else elevations.length = Math.min(elevations.length, 255);
    this.#elevations = new Float64Array(elevations);
    this.renderDirty = true;
  }

  /* -------------------------------------------- */

  /**
   * Update the current occlusion status of all Tile objects.
   * @internal
   */
  _updateOcclusionStates() {
    const occluded = this._identifyOccludedObjects(this.#tokens);
    for ( const pco of canvas.primary.children ) {
      const isOccludable = pco.isOccludable;
      if ( (isOccludable === undefined) || (!isOccludable && !pco.occluded) ) continue;
      pco.debounceSetOcclusion(occluded.has(pco));
    }
  }

  /* -------------------------------------------- */

  /**
   * Determine the set of objects which should be currently occluded by a Token.
   * @param {Token[]} tokens                   The set of currently controlled Token objects
   * @returns {Set<PrimaryCanvasObjectMixin>}  The PCO objects which should be currently occluded
   * @protected
   */
  _identifyOccludedObjects(tokens) {
    const occluded = new Set();
    for ( const token of tokens ) {
      // Get the occludable primary canvas objects (PCO) according to the token bounds
      const matchingPCO = canvas.primary.quadtree.getObjects(token.bounds);
      for ( const pco of matchingPCO ) {
        // Don't bother re-testing a PCO or an object which is not occludable
        if ( !pco.isOccludable || occluded.has(pco) ) continue;
        if ( pco.testOcclusion(token, {corners: pco.restrictsLight && pco.restrictsWeather}) ) occluded.add(pco);
      }
    }
    return occluded;
  }

  /* -------------------------------------------- */
  /*  Deprecation and compatibility               */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  _identifyOccludedTiles() {
    const msg = "CanvasOcclusionMask#_identifyOccludedTiles has been deprecated in " +
      "favor of CanvasOcclusionMask#_identifyOccludedObjects.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return this._identifyOccludedObjects();
  }
}

/**
 * @typedef {object} _CanvasVisionContainerSight
 * @property {PIXI.LegacyGraphics} preview    FOV that should not be committed to fog exploration.
 */

/**
 * The sight part of {@link CanvasVisionContainer}.
 * The blend mode is MAX_COLOR.
 * @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerSight} CanvasVisionContainerSight
 */

/**
 * @typedef {object} _CanvasVisionContainerLight
 * @property {PIXI.LegacyGraphics} preview    FOV that should not be committed to fog exploration.
 * @property {SpriteMesh} cached              The sprite with the texture of FOV of cached light sources.
 * @property {PIXI.LegacyGraphics & {preview: PIXI.LegacyGraphics}} mask
 *   The light perception polygons of vision sources and the FOV of vision sources that provide vision.
 */

/**
 * The light part of {@link CanvasVisionContainer}.
 * The blend mode is MAX_COLOR.
 * @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerLight} CanvasVisionContainerLight
 */

/**
 * @typedef {object} _CanvasVisionContainerDarkness
 * @property {PIXI.LegacyGraphics} darkness    Darkness source erasing fog of war.
 */

/**
 * The sight part of {@link CanvasVisionContainer}.
 * The blend mode is ERASE.
 * @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerDarkness} CanvasVisionContainerDarkness
 */

/**
 * The sight part of {@link CanvasVisionContainer}.
 * The blend mode is MAX_COLOR.
 * @typedef {PIXI.LegacyGraphics & _CanvasVisionContainerSight} CanvasVisionContainerSight
 */

/**
 * @typedef {object} _CanvasVisionContainer
 * @property {CanvasVisionContainerLight} light       Areas visible because of light sources and light perception.
 * @property {CanvasVisionContainerSight} sight       Areas visible because of FOV of vision sources.
 * @property {CanvasVisionContainerDarkness} darkness Areas erased by darkness sources.
 */

/**
 * The currently visible areas.
 * @typedef {PIXI.Container & _CanvasVisionContainer} CanvasVisionContainer
 */

/**
 * The vision mask which contains the current line-of-sight texture.
 * @category - Canvas
 */
class CanvasVisionMask extends CachedContainer {

  /** @override */
  static textureConfiguration = {
    scaleMode: PIXI.SCALE_MODES.NEAREST,
    format: PIXI.FORMATS.RED,
    multisample: PIXI.MSAA_QUALITY.NONE
  };

  /** @override */
  clearColor = [0, 0, 0, 0];

  /** @override */
  autoRender = false;

  /**
   * The current vision Container.
   * @type {CanvasVisionContainer}
   */
  vision;

  /**
   * The BlurFilter which applies to the vision mask texture.
   * This filter applies a NORMAL blend mode to the container.
   * @type {AlphaBlurFilter}
   */
  blurFilter;

  /* -------------------------------------------- */

  /**
   * Create the BlurFilter for the VisionMask container.
   * @returns {AlphaBlurFilter}
   */
  #createBlurFilter() {
    // Initialize filters properties
    this.filters ??= [];
    this.filterArea = null;

    // Check if the canvas blur is disabled and return without doing anything if necessary
    const b = canvas.blur;
    this.filters.findSplice(f => f === this.blurFilter);
    if ( !b.enabled ) return;

    // Create the new filter
    const f = this.blurFilter = new b.blurClass(b.strength, b.passes, PIXI.Filter.defaultResolution, b.kernels);
    f.blendMode = PIXI.BLEND_MODES.NORMAL;
    this.filterArea = canvas.app.renderer.screen;
    this.filters.push(f);
    return canvas.addBlurFilter(this.blurFilter);
  }

  /* -------------------------------------------- */

  async draw() {
    this.#createBlurFilter();
  }

  /* -------------------------------------------- */

  /**
   * Initialize the vision mask with the los and the fov graphics objects.
   * @param {PIXI.Container} vision         The vision container to attach
   * @returns {CanvasVisionContainer}
   */
  attachVision(vision) {
    return this.vision = this.addChild(vision);
  }

  /* -------------------------------------------- */

  /**
   * Detach the vision mask from the cached container.
   * @returns {CanvasVisionContainer} The detached vision container.
   */
  detachVision() {
    const vision = this.vision;
    this.removeChild(vision);
    this.vision = undefined;
    return vision;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  get filter() {
    foundry.utils.logCompatibilityWarning("CanvasVisionMask#filter has been renamed to blurFilter.", {since: 11, until: 13});
    return this.blurFilter;
  }

  /**
   * @deprecated since v11
   * @ignore
   */
  set filter(f) {
    foundry.utils.logCompatibilityWarning("CanvasVisionMask#filter has been renamed to blurFilter.", {since: 11, until: 13});
    this.blurFilter = f;
  }
}

/**
 * The DrawingsLayer subclass of PlaceablesLayer.
 * This layer implements a container for drawings.
 * @category - Canvas
 */
class DrawingsLayer extends PlaceablesLayer {

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "drawings",
      controllableObjects: true,
      rotatableObjects: true,
      zIndex: 500
    });
  }

  /** @inheritdoc */
  static documentName = "Drawing";

  /**
   * The named game setting which persists default drawing configuration for the User
   * @type {string}
   */
  static DEFAULT_CONFIG_SETTING = "defaultDrawingConfig";

  /**
   * The collection of drawing objects which are rendered in the interface.
   * @type {Collection<string, Drawing>}
   */
  graphics = new foundry.utils.Collection();

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritdoc */
  get hud() {
    return canvas.hud.drawing;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get hookName() {
    return DrawingsLayer.name;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @override */
  getSnappedPoint(point) {
    const M = CONST.GRID_SNAPPING_MODES;
    const size = canvas.dimensions.size;
    return canvas.grid.getSnappedPoint(point, canvas.forceSnapVertices ? {mode: M.VERTEX} : {
      mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
      resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2)
    });
  }

  /* -------------------------------------------- */

  /**
   * Render a configuration sheet to configure the default Drawing settings
   */
  configureDefault() {
    const defaults = game.settings.get("core", DrawingsLayer.DEFAULT_CONFIG_SETTING);
    const d = DrawingDocument.fromSource(defaults);
    new DrawingConfig(d, {configureDefault: true}).render(true);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _deactivate() {
    super._deactivate();
    this.objects.visible = true;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _draw(options) {
    await super._draw(options);
    this.objects.visible = true;
  }

  /* -------------------------------------------- */

  /**
   * Get initial data for a new drawing.
   * Start with some global defaults, apply user default config, then apply mandatory overrides per tool.
   * @param {Point} origin      The initial coordinate
   * @returns {object}          The new drawing data
   */
  _getNewDrawingData(origin) {
    const tool = game.activeTool;

    // Get saved user defaults
    const defaults = game.settings.get("core", this.constructor.DEFAULT_CONFIG_SETTING) || {};
    const userColor = game.user.color.css;
    const data = foundry.utils.mergeObject(defaults, {
      fillColor: userColor,
      strokeColor: userColor,
      fontFamily: CONFIG.defaultFontFamily
    }, {overwrite: false, inplace: false});

    // Mandatory additions
    delete data._id;
    data.x = origin.x;
    data.y = origin.y;
    data.sort = Math.max(this.getMaxSort() + 1, 0);
    data.author = game.user.id;
    data.shape = {};

    // Information toggle
    const interfaceToggle = ui.controls.controls.find(c => c.layer === "drawings").tools.find(t => t.name === "role");
    data.interface = interfaceToggle.active;

    // Tool-based settings
    switch ( tool ) {
      case "rect":
        data.shape.type = Drawing.SHAPE_TYPES.RECTANGLE;
        data.shape.width = 1;
        data.shape.height = 1;
        break;
      case "ellipse":
        data.shape.type = Drawing.SHAPE_TYPES.ELLIPSE;
        data.shape.width = 1;
        data.shape.height = 1;
        break;
      case "polygon":
        data.shape.type = Drawing.SHAPE_TYPES.POLYGON;
        data.shape.points = [0, 0];
        data.bezierFactor = 0;
        break;
      case "freehand":
        data.shape.type = Drawing.SHAPE_TYPES.POLYGON;
        data.shape.points = [0, 0];
        data.bezierFactor = data.bezierFactor ?? 0.5;
        break;
      case "text":
        data.shape.type = Drawing.SHAPE_TYPES.RECTANGLE;
        data.shape.width = 1;
        data.shape.height = 1;
        data.fillColor = "#ffffff";
        data.fillAlpha = 0.10;
        data.strokeColor = "#ffffff";
        data.text ||= "";
        break;
    }

    // Return the cleaned data
    return DrawingDocument.cleanData(data);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _onClickLeft(event) {
    const {preview, drawingsState, destination} = event.interactionData;

    // Continue polygon point placement
    if ( (drawingsState >= 1) && preview.isPolygon ) {
      preview._addPoint(destination, {snap: !event.shiftKey, round: true});
      preview._chain = true; // Note that we are now in chain mode
      return preview.refresh();
    }

    // Standard left-click handling
    super._onClickLeft(event);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onClickLeft2(event) {
    const {drawingsState, preview} = event.interactionData;

    // Conclude polygon placement with double-click
    if ( (drawingsState >= 1) && preview.isPolygon ) {
      event.interactionData.drawingsState = 2;
      return;
    }

    // Standard double-click handling
    super._onClickLeft2(event);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftStart(event) {
    super._onDragLeftStart(event);
    const interaction = event.interactionData;

    // Snap the origin to the grid
    const isFreehand = game.activeTool === "freehand";
    if ( !event.shiftKey && !isFreehand ) {
      interaction.origin = this.getSnappedPoint(interaction.origin);
    }

    // Create the preview object
    const cls = getDocumentClass("Drawing");
    let document;
    try {
      document = new cls(this._getNewDrawingData(interaction.origin), {parent: canvas.scene});
    }
    catch(e) {
      if ( e instanceof foundry.data.validation.DataModelValidationError ) {
        ui.notifications.error("DRAWING.JointValidationErrorUI", {localize: true});
      }
      throw e;
    }
    const drawing = new this.constructor.placeableClass(document);
    interaction.preview = this.preview.addChild(drawing);
    interaction.drawingsState = 1;
    drawing.draw();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftMove(event) {
    const {preview, drawingsState} = event.interactionData;
    if ( !preview || preview._destroyed ) return;
    if ( preview.parent === null ) { // In theory this should never happen, but rarely does
      this.preview.addChild(preview);
    }
    if ( drawingsState >= 1 ) {
      preview._onMouseDraw(event);
      const isFreehand = game.activeTool === "freehand";
      if ( !preview.isPolygon || isFreehand ) event.interactionData.drawingsState = 2;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handling of mouse-up events which conclude a new object creation after dragging
   * @param {PIXI.FederatedEvent} event       The drag drop event
   * @private
   */
  _onDragLeftDrop(event) {
    const interaction = event.interactionData;

    // Snap the destination to the grid
    const isFreehand = game.activeTool === "freehand";
    if ( !event.shiftKey && !isFreehand ) {
      interaction.destination = this.getSnappedPoint(interaction.destination);
    }

    const {drawingsState, destination, origin, preview} = interaction;

    // Successful drawing completion
    if ( drawingsState === 2 ) {
      const distance = Math.hypot(Math.max(destination.x, origin.x) - preview.x,
        Math.max(destination.y, origin.x) - preview.y);
      const minDistance = distance >= (canvas.dimensions.size / 8);
      const completePolygon = preview.isPolygon && (preview.document.shape.points.length > 4);

      // Create a completed drawing
      if ( minDistance || completePolygon ) {
        event.interactionData.clearPreviewContainer = false;
        event.interactionData.drawingsState = 0;
        const data = preview.document.toObject(false);

        // Create the object
        preview._chain = false;
        const cls = getDocumentClass("Drawing");
        const createData = this.constructor.placeableClass.normalizeShape(data);
        cls.create(createData, {parent: canvas.scene}).then(d => {
          const o = d.object;
          o._creating = true;
          if ( game.activeTool !== "freehand" ) o.control({isNew: true});
        }).finally(() => this.clearPreviewContainer());
      }
    }

    // In-progress polygon
    if ( (drawingsState === 1) && preview.isPolygon ) {
      event.preventDefault();
      if ( preview._chain ) return;
      return this._onClickLeft(event);
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftCancel(event) {
    const preview = this.preview.children?.[0] || null;
    if ( preview?._chain ) {
      preview._removePoint();
      preview.refresh();
      if ( preview.document.shape.points.length ) return event.preventDefault();
    }
    event.interactionData.drawingsState = 0;
    super._onDragLeftCancel(event);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onClickRight(event) {
    const preview = this.preview.children?.[0] || null;
    if ( preview ) return canvas.mouseInteractionManager._dragRight = false;
    super._onClickRight(event);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get gridPrecision() {
    // eslint-disable-next-line no-unused-expressions
    super.gridPrecision;
    if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) return 0;
    return canvas.dimensions.size >= 128 ? 16 : 8;
  }
}

/**
 * The Lighting Layer which ambient light sources as part of the CanvasEffectsGroup.
 * @category - Canvas
 */
class LightingLayer extends PlaceablesLayer {

  /** @inheritdoc */
  static documentName = "AmbientLight";

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "lighting",
      rotatableObjects: true,
      zIndex: 900
    });
  }

  /**
   * Darkness change event handler function.
   * @type {_onDarknessChange}
   */
  #onDarknessChange;

  /* -------------------------------------------- */

  /** @inheritdoc */
  get hookName() {
    return LightingLayer.name;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);
    this.#onDarknessChange = this._onDarknessChange.bind(this);
    canvas.environment.addEventListener("darknessChange", this.#onDarknessChange);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    canvas.environment.removeEventListener("darknessChange", this.#onDarknessChange);
    this.#onDarknessChange = undefined;
    return super._tearDown(options);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Refresh the fields of all the ambient lights on this scene.
   */
  refreshFields() {
    if ( !this.active ) return;
    for ( const ambientLight of this.placeables ) {
      ambientLight.renderFlags.set({refreshField: true});
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _activate() {
    super._activate();
    for ( const p of this.placeables ) p.renderFlags.set({refreshField: true});
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _canDragLeftStart(user, event) {
    // Prevent creating a new light if currently previewing one.
    if ( this.preview.children.length ) {
      ui.notifications.warn("CONTROLS.ObjectConfigured", { localize: true });
      return false;
    }
    return super._canDragLeftStart(user, event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftStart(event) {
    super._onDragLeftStart(event);
    const interaction = event.interactionData;

    // Snap the origin to the grid
    if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);

    // Create a pending AmbientLightDocument
    const cls = getDocumentClass("AmbientLight");
    const doc = new cls(interaction.origin, {parent: canvas.scene});

    // Create the preview AmbientLight object
    const preview = new this.constructor.placeableClass(doc);

    // Updating interaction data
    interaction.preview = this.preview.addChild(preview);
    interaction.lightsState = 1;

    // Prepare to draw the preview
    preview.draw();
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftMove(event) {
    const {destination, lightsState, preview, origin} = event.interactionData;
    if ( lightsState === 0 ) return;

    // Update the light radius
    const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y);

    // Update the preview object data
    preview.document.config.dim = radius * (canvas.dimensions.distance / canvas.dimensions.size);
    preview.document.config.bright = preview.document.config.dim / 2;

    // Refresh the layer display
    preview.initializeLightSource();
    preview.renderFlags.set({refreshState: true});

    // Confirm the creation state
    event.interactionData.lightsState = 2;
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftCancel(event) {
    super._onDragLeftCancel(event);
    canvas.effects.refreshLighting();
    event.interactionData.lightsState = 0;
  }

  /* -------------------------------------------- */

  /** @override */
  _onMouseWheel(event) {

    // Identify the hovered light source
    const light = this.hover;
    if ( !light || light.isPreview || (light.document.config.angle === 360) ) return;

    // Determine the incremental angle of rotation from event data
    const snap = event.shiftKey ? 15 : 3;
    const delta = snap * Math.sign(event.delta);
    return light.rotate(light.document.rotation + delta, snap);
  }

  /* -------------------------------------------- */

  /**
   * Actions to take when the darkness level of the Scene is changed
   * @param {PIXI.FederatedEvent} event
   * @internal
   */
  _onDarknessChange(event) {
    const {darknessLevel, priorDarknessLevel} = event.environmentData;
    for ( const light of this.placeables ) {
      const {min, max} = light.document.config.darkness;
      if ( darknessLevel.between(min, max) === priorDarknessLevel.between(min, max) ) continue;
      light.initializeLightSource();
      if ( this.active ) light.renderFlags.set({refreshState: true});
    }
  }
}

/**
 * The Notes Layer which contains Note canvas objects.
 * @category - Canvas
 */
class NotesLayer extends PlaceablesLayer {

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "notes",
      zIndex: 800
    });
  }

  /** @inheritdoc */
  static documentName = "Note";

  /**
   * The named core setting which tracks the toggled visibility state of map notes
   * @type {string}
   */
  static TOGGLE_SETTING = "notesDisplayToggle";

  /* -------------------------------------------- */

  /** @inheritdoc */
  get hookName() {
    return NotesLayer.name;
  }

  /* -------------------------------------------- */

  /** @override */
  interactiveChildren = game.settings.get("core", this.constructor.TOGGLE_SETTING);

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /** @override */
  _deactivate() {
    super._deactivate();
    const isToggled = game.settings.get("core", this.constructor.TOGGLE_SETTING);
    this.objects.visible = this.interactiveChildren = isToggled;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);
    const isToggled = game.settings.get("core", this.constructor.TOGGLE_SETTING);
    this.objects.visible ||= isToggled;
  }

  /* -------------------------------------------- */

  /**
   * Register game settings used by the NotesLayer
   */
  static registerSettings() {
    game.settings.register("core", this.TOGGLE_SETTING, {
      name: "Map Note Toggle",
      scope: "client",
      config: false,
      type: new foundry.data.fields.BooleanField({initial: false}),
      onChange: value => {
        if ( !canvas.ready ) return;
        const layer = canvas.notes;
        layer.objects.visible = layer.interactiveChildren = layer.active || value;
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Visually indicate in the Scene Controls that there are visible map notes present in the Scene.
   */
  hintMapNotes() {
    const hasVisibleNotes = this.placeables.some(n => n.visible);
    const i = document.querySelector(".scene-control[data-control='notes'] i");
    i.classList.toggle("fa-solid", !hasVisibleNotes);
    i.classList.toggle("fa-duotone", hasVisibleNotes);
    i.classList.toggle("has-notes", hasVisibleNotes);
  }

  /* -------------------------------------------- */

  /**
   * Pan to a given note on the layer.
   * @param {Note} note                      The note to pan to.
   * @param {object} [options]               Options which modify the pan operation.
   * @param {number} [options.scale=1.5]     The resulting zoom level.
   * @param {number} [options.duration=250]  The speed of the pan animation in milliseconds.
   * @returns {Promise<void>}                A Promise which resolves once the pan animation has concluded.
   */
  panToNote(note, {scale=1.5, duration=250}={}) {
    if ( !note ) return Promise.resolve();
    if ( note.visible && !this.active ) this.activate();
    return canvas.animatePan({x: note.x, y: note.y, scale, duration}).then(() => {
      if ( this.hover ) this.hover._onHoverOut(new Event("pointerout"));
      note._onHoverIn(new Event("pointerover"), {hoverOutOthers: true});
    });
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onClickLeft(event) {
    if ( game.activeTool !== "journal" ) return super._onClickLeft(event);

    // Capture the click coordinates
    const origin = event.getLocalPosition(canvas.stage);
    const {x, y} = canvas.grid.getCenterPoint(origin);

    // Render the note creation dialog
    const folders = game.journal.folders.filter(f => f.displayed);
    const title = game.i18n.localize("NOTE.Create");
    const html = await renderTemplate("templates/sidebar/document-create.html", {
      folders,
      name: game.i18n.localize("NOTE.Unknown"),
      hasFolders: folders.length >= 1,
      hasTypes: false,
      content: `
        <div class="form-group">
            <label style="display: flex;">
                <input type="checkbox" name="journal">
                ${game.i18n.localize("NOTE.CreateJournal")}
            </label>
        </div>
      `
    });
    let response;
    try {
      response = await Dialog.prompt({
        title,
        content: html,
        label: game.i18n.localize("NOTE.Create"),
        callback: html => {
          const form = html.querySelector("form");
          const fd = new FormDataExtended(form).object;
          if ( !fd.folder ) delete fd.folder;
          if ( fd.journal ) return JournalEntry.implementation.create(fd, {renderSheet: true});
          return fd.name;
        },
        render: html => {
          const form = html.querySelector("form");
          const folder = form.elements.folder;
          if ( !folder ) return;
          folder.disabled = true;
          form.elements.journal.addEventListener("change", event => {
            folder.disabled = !event.currentTarget.checked;
          });
        },
        options: {jQuery: false}
      });
    } catch(err) {
      return;
    }

    // Create a note for a created JournalEntry
    const noteData = {x, y};
    if ( response.id ) {
      noteData.entryId = response.id;
      const cls = getDocumentClass("Note");
      return cls.create(noteData, {parent: canvas.scene});
    }

    // Create a preview un-linked Note
    else {
      noteData.text = response;
      return this._createPreview(noteData, {top: event.clientY - 20, left: event.clientX + 40});
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle JournalEntry document drop data
   * @param {DragEvent} event   The drag drop event
   * @param {object} data       The dropped data transfer data
   * @protected
   */
  async _onDropData(event, data) {
    let entry;
    let origin;
    if ( (data.x === undefined) || (data.y === undefined) ) {
      const coords = this._canvasCoordinatesFromDrop(event, {center: false});
      if ( !coords ) return false;
      origin = {x: coords[0], y: coords[1]};
    } else {
      origin = {x: data.x, y: data.y};
    }
    if ( !event.shiftKey ) origin = this.getSnappedPoint(origin);
    if ( !canvas.dimensions.rect.contains(origin.x, origin.y) ) return false;
    const noteData = {x: origin.x, y: origin.y};
    if ( data.type === "JournalEntry" ) entry = await JournalEntry.implementation.fromDropData(data);
    if ( data.type === "JournalEntryPage" ) {
      const page = await JournalEntryPage.implementation.fromDropData(data);
      entry = page.parent;
      noteData.pageId = page.id;
    }
    if ( entry?.compendium ) {
      const journalData = game.journal.fromCompendium(entry);
      entry = await JournalEntry.implementation.create(journalData);
    }
    noteData.entryId = entry?.id;
    return this._createPreview(noteData, {top: event.clientY - 20, left: event.clientX + 40});
  }
}

/**
 * The Regions Container.
 * @category - Canvas
 */
class RegionLayer extends PlaceablesLayer {

  /** @inheritDoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "regions",
      controllableObjects: true,
      confirmDeleteKey: true,
      quadtree: false,
      zIndex: 100,
      zIndexActive: 600
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static documentName = "Region";

  /* -------------------------------------------- */

  /**
   * The method to sort the Regions.
   * @type {Function}
   */
  static #sortRegions = function() {
    for ( let i = 0; i < this.children.length; i++ ) {
      this.children[i]._lastSortedIndex = i;
    }
    this.children.sort((a, b) => (a.zIndex - b.zIndex)
      || (a.top - b.top)
      || (a.bottom - b.bottom)
      || (a._lastSortedIndex - b._lastSortedIndex));
    this.sortDirty = false;
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  get hookName() {
    return RegionLayer.name;
  }

  /* -------------------------------------------- */

  /**
   * The RegionLegend application of this RegionLayer.
   * @type {foundry.applications.ui.RegionLegend}
   */
  get legend() {
    return this.#legend ??= new foundry.applications.ui.RegionLegend();
  }

  #legend;

  /* -------------------------------------------- */

  /**
   * The graphics used to draw the highlighted shape.
   * @type {PIXI.Graphics}
   */
  #highlight;

  /* -------------------------------------------- */

  /**
   * The graphics used to draw the preview of the shape that is drawn.
   * @type {PIXI.Graphics}
   */
  #preview;

  /* -------------------------------------------- */

  /**
   * Draw shapes as holes?
   * @type {boolean}
   * @internal
   */
  _holeMode = false;

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /** @inheritDoc */
  _activate() {
    super._activate();
    // noinspection ES6MissingAwait
    this.legend.render({force: true});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _deactivate() {
    super._deactivate();
    this.objects.visible = true;
    // noinspection ES6MissingAwait
    this.legend.close({animate: false});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  storeHistory(type, data) {
    super.storeHistory(type, type === "update" ? data.map(d => {
      if ( "behaviors" in d ) {
        d = foundry.utils.deepClone(d);
        delete d.behaviors;
      }
      return d;
    }) : data);
  }

  /* -------------------------------------------- */

  /** @override */
  copyObjects() {
    return []; // Prevent copy & paste
  }

  /* -------------------------------------------- */

  /** @override */
  getSnappedPoint(point) {
    const M = CONST.GRID_SNAPPING_MODES;
    const size = canvas.dimensions.size;
    return canvas.grid.getSnappedPoint(point, canvas.forceSnapVertices ? {mode: M.VERTEX} : {
      mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
      resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2)
    });
  }

  /* -------------------------------------------- */

  /** @override */
  getZIndex() {
    return this.active ? this.options.zIndexActive : this.options.zIndex;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);
    this.objects.sortChildren = RegionLayer.#sortRegions;
    this.objects.visible = true;
    this.#highlight = this.addChild(new PIXI.Graphics());
    this.#highlight.eventMode = "none";
    this.#highlight.visible = false;
    this.#preview = this.addChild(new PIXI.Graphics());
    this.#preview.eventMode = "none";
    this.#preview.visible = false;
    this.filters = [VisionMaskFilter.create()];
    this.filterArea = canvas.app.screen;
  }

  /* -------------------------------------------- */

  /**
   * Highlight the shape or clear the highlight.
   * @param {foundry.data.BaseShapeData|null} data    The shape to highlight, or null to clear the highlight
   * @internal
   */
  _highlightShape(data) {
    this.#highlight.clear();
    this.#highlight.visible = false;
    if ( !data ) return;
    this.#highlight.visible = true;
    this.#highlight.lineStyle({
      width: CONFIG.Canvas.objectBorderThickness,
      color: 0x000000,
      join: PIXI.LINE_JOIN.ROUND,
      shader: new PIXI.smooth.DashLineShader()
    });
    const shape = foundry.canvas.regions.RegionShape.create(data);
    shape._drawShape(this.#highlight);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the preview shape.
   * @param {PIXI.FederatedEvent} event
   */
  #refreshPreview(event) {
    this.#preview.clear();
    this.#preview.lineStyle({
      width: CONFIG.Canvas.objectBorderThickness,
      color: 0x000000,
      join: PIXI.LINE_JOIN.ROUND,
      cap: PIXI.LINE_CAP.ROUND,
      alignment: 0.75
    });
    this.#preview.beginFill(event.interactionData.drawingColor, 0.5);
    this.#drawPreviewShape(event);
    this.#preview.endFill();
    this.#preview.lineStyle({
      width: CONFIG.Canvas.objectBorderThickness / 2,
      color: CONFIG.Canvas.dispositionColors.CONTROLLED,
      join: PIXI.LINE_JOIN.ROUND,
      cap: PIXI.LINE_CAP.ROUND,
      alignment: 1
    });
    this.#drawPreviewShape(event);
  }

  /* -------------------------------------------- */

  /**
   * Draw the preview shape.
   * @param {PIXI.FederatedEvent} event
   */
  #drawPreviewShape(event) {
    const data = this.#createShapeData(event);
    if ( !data ) return;
    switch ( data.type ) {
      case "rectangle": this.#preview.drawRect(data.x, data.y, data.width, data.height); break;
      case "circle": this.#preview.drawCircle(data.x, data.y, data.radius); break;
      case "ellipse": this.#preview.drawEllipse(data.x, data.y, data.radiusX, data.radiusY); break;
      case "polygon":
        const polygon = new PIXI.Polygon(data.points);
        if ( !polygon.isPositive ) polygon.reverseOrientation();
        this.#preview.drawPath(polygon.points);
        break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Create the shape data.
   * @param {PIXI.FederatedEvent} event
   * @returns {object|void}
   */
  #createShapeData(event) {
    let data;
    switch ( event.interactionData.drawingTool ) {
      case "rectangle": data = this.#createRectangleData(event); break;
      case "ellipse": data = this.#createCircleOrEllipseData(event); break;
      case "polygon": data = this.#createPolygonData(event); break;
    }
    if ( data ) {
      data.elevation = {
        bottom: Number.isFinite(this.legend.elevation.bottom) ? this.legend.elevation.bottom : null,
        top: Number.isFinite(this.legend.elevation.top) ? this.legend.elevation.top : null
      };
      if ( this._holeMode ) data.hole = true;
      return data;
    }
  }

  /* -------------------------------------------- */

  /**
   * Create the rectangle shape data.
   * @param {PIXI.FederatedEvent} event
   * @returns {object|void}
   */
  #createRectangleData(event) {
    const {origin, destination} = event.interactionData;
    let dx = Math.abs(destination.x - origin.x);
    let dy = Math.abs(destination.y - origin.y);
    if ( event.altKey ) dx = dy = Math.min(dx, dy);
    let x = origin.x;
    let y = origin.y;
    if ( event.ctrlKey || event.metaKey ) {
      x -= dx;
      y -= dy;
      dx *= 2;
      dy *= 2;
    } else {
      if ( origin.x > destination.x ) x -= dx;
      if ( origin.y > destination.y ) y -= dy;
    }
    if ( (dx === 0) || (dy === 0) ) return;
    return {type: "rectangle", x, y, width: dx, height: dy, rotation: 0};
  }

  /* -------------------------------------------- */

  /**
   * Create the circle or ellipse shape data.
   * @param {PIXI.FederatedEvent} event
   * @returns {object|void}
   */
  #createCircleOrEllipseData(event) {
    const {origin, destination} = event.interactionData;
    let dx = Math.abs(destination.x - origin.x);
    let dy = Math.abs(destination.y - origin.y);
    if ( event.altKey ) dx = dy = Math.min(dx, dy);
    let x = origin.x;
    let y = origin.y;
    if ( !(event.ctrlKey || event.metaKey) ) {
      if ( origin.x > destination.x ) x -= dx;
      if ( origin.y > destination.y ) y -= dy;
      dx /= 2;
      dy /= 2;
      x += dx;
      y += dy;
    }
    if ( (dx === 0) || (dy === 0) ) return;
    return event.altKey
      ? {type: "circle", x, y, radius: dx}
      : {type: "ellipse", x, y, radiusX: dx, radiusY: dy, rotation: 0};
  }

  /* -------------------------------------------- */

  /**
   * Create the polygon shape data.
   * @param {PIXI.FederatedEvent} event
   * @returns {object|void}
   */
  #createPolygonData(event) {
    let {destination, points, complete} = event.interactionData;
    if ( !complete ) points = [...points, destination.x, destination.y];
    else if ( points.length < 6 ) return;
    return {type: "polygon", points};
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickLeft(event) {
    const interaction = event.interactionData;

    // Continue polygon point placement
    if ( interaction.drawingTool === "polygon" ) {
      const {destination, points} = interaction;
      const point = !event.shiftKey ? this.getSnappedPoint(destination) : destination;

      // Clicking on the first point closes the shape
      if ( (point.x === points.at(0)) && (point.y === points.at(1)) ) {
        interaction.complete = true;
      }

      // Don't add the point if it is equal to the last one
      else if ( (point.x !== points.at(-2)) || (point.y !== points.at(-1)) ) {
        interaction.points.push(point.x, point.y);
        this.#refreshPreview(event);
      }
      return;
    }

    // If one of the drawing tools is selected, prevent left-click-to-release
    if ( ["rectangle", "ellipse", "polygon"].includes(game.activeTool) ) return;

    // Standard left-click handling
    super._onClickLeft(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickLeft2(event) {
    const interaction = event.interactionData;

    // Conclude polygon drawing with a double-click
    if ( interaction.drawingTool === "polygon" ) {
      interaction.complete = true;
      return;
    }

    // Standard double-click handling
    super._onClickLeft2(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _canDragLeftStart(user, event) {
    if ( !super._canDragLeftStart(user, event) ) return false;
    if ( !["rectangle", "ellipse", "polygon"].includes(game.activeTool) ) return false;
    if ( this.controlled.length > 1 ) {
      ui.notifications.error("REGION.NOTIFICATIONS.DrawingMultipleRegionsControlled", {localize: true});
      return false;
    }
    if ( this.controlled.at(0)?.document.locked ) {
      ui.notifications.warn(game.i18n.format("CONTROLS.ObjectIsLocked", {
        type: game.i18n.localize(RegionDocument.metadata.label)}));
      return false;
    }
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftStart(event) {
    const interaction = event.interactionData;
    if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);

    // Set drawing tool
    interaction.drawingTool = game.activeTool;
    interaction.drawingRegion = this.controlled.at(0);
    interaction.drawingColor = interaction.drawingRegion?.document.color
      ?? Color.from(RegionDocument.schema.fields.color.getInitialValue({}));

    // Initialize the polygon points with the origin
    if ( interaction.drawingTool === "polygon" ) {
      const point = interaction.origin;
      interaction.points = [point.x, point.y];
    }
    this.#refreshPreview(event);
    this.#preview.visible = true;
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftMove(event) {
    const interaction = event.interactionData;
    if ( !interaction.drawingTool ) return;
    if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
    this.#refreshPreview(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftDrop(event) {
    const interaction = event.interactionData;
    if ( !interaction.drawingTool ) return;
    if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);

    // In-progress polygon drawing
    if ( (interaction.drawingTool === "polygon") && (interaction.complete !== true) ) {
      event.preventDefault();
      return;
    }

    // Clear preview and refresh Regions
    this.#preview.clear();
    this.#preview.visible = false;

    // Create the shape from the preview
    const shape = this.#createShapeData(event);
    if ( !shape ) return;

    // Add the shape to controlled Region or create a new Region if none is controlled
    const region = interaction.drawingRegion;
    if ( region ) {
      if ( !region.document.locked ) region.document.update({shapes: [...region.document.shapes, shape]});
    } else RegionDocument.implementation.create({
      name: RegionDocument.implementation.defaultName({parent: canvas.scene}),
      color: interaction.drawingColor,
      shapes: [shape]
    }, {parent: canvas.scene, renderSheet: true}).then(r => r.object.control({releaseOthers: true}));
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftCancel(event) {
    const interaction = event.interactionData;
    if ( !interaction.drawingTool ) return;

    // Remove point from in-progress polygon drawing
    if ( (interaction.drawingTool === "polygon") && (interaction.complete !== true) ) {
      interaction.points.splice(-2, 2);
      if ( interaction.points.length ) {
        event.preventDefault();
        this.#refreshPreview(event);
        return;
      }
    }

    // Clear preview and refresh Regions
    this.#preview.clear();
    this.#preview.visible = false;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickRight(event) {
    const interaction = event.interactionData;
    if ( interaction.drawingTool ) return canvas.mouseInteractionManager._dragRight = false;
    super._onClickRight(event);
  }
}

/**
 * @typedef {Object} AmbientSoundPlaybackConfig
 * @property {Sound} sound              The Sound node which should be controlled for playback
 * @property {foundry.canvas.sources.PointSoundSource} source  The SoundSource which defines the area of effect
 *                                                             for the sound
 * @property {AmbientSound} object      An AmbientSound object responsible for the sound, or undefined
 * @property {Point} listener           The coordinates of the closest listener or undefined if there is none
 * @property {number} distance          The minimum distance between a listener and the AmbientSound origin
 * @property {boolean} muffled          Is the closest listener muffled
 * @property {boolean} walls            Is playback constrained or muffled by walls?
 * @property {number} volume            The final volume at which the Sound should be played
 */

/**
 * This Canvas Layer provides a container for AmbientSound objects.
 * @category - Canvas
 */
class SoundsLayer extends PlaceablesLayer {

  /**
   * Track whether to actively preview ambient sounds with mouse cursor movements
   * @type {boolean}
   */
  livePreview = false;

  /**
   * A mapping of ambient audio sources which are active within the rendered Scene
   * @type {Collection<string,foundry.canvas.sources.PointSoundSource>}
   */
  sources = new foundry.utils.Collection();

  /**
   * Darkness change event handler function.
   * @type {_onDarknessChange}
   */
  #onDarknessChange;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "sounds",
      zIndex: 900
    });
  }

  /** @inheritdoc */
  static documentName = "AmbientSound";

  /* -------------------------------------------- */

  /** @inheritdoc */
  get hookName() {
    return SoundsLayer.name;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);
    this.#onDarknessChange = this._onDarknessChange.bind(this);
    canvas.environment.addEventListener("darknessChange", this.#onDarknessChange);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    this.stopAll();
    canvas.environment.removeEventListener("darknessChange", this.#onDarknessChange);
    this.#onDarknessChange = undefined;
    return super._tearDown(options);
  }

  /* -------------------------------------------- */

  /** @override */
  _activate() {
    super._activate();
    for ( const p of this.placeables ) p.renderFlags.set({refreshField: true});
  }

  /* -------------------------------------------- */

  /**
   * Initialize all AmbientSound sources which are present on this layer
   */
  initializeSources() {
    for ( let sound of this.placeables ) {
      sound.initializeSoundSource();
    }
    for ( let sound of this.preview.children ) {
      sound.initializeSoundSource();
    }
  }

  /* -------------------------------------------- */

  /**
   * Update all AmbientSound effects in the layer by toggling their playback status.
   * Sync audio for the positions of tokens which are capable of hearing.
   * @param {object} [options={}]   Additional options forwarded to AmbientSound synchronization
   */
  refresh(options={}) {
    if ( !this.placeables.length ) return;
    for ( const sound of this.placeables ) sound.source.refresh();
    if ( game.audio.locked ) {
      return game.audio.pending.push(() => this.refresh(options));
    }
    const listeners = this.getListenerPositions();
    this._syncPositions(listeners, options);
  }

  /* -------------------------------------------- */

  /**
   * Preview ambient audio for a given mouse cursor position
   * @param {Point} position      The cursor position to preview
   */
  previewSound(position) {
    if ( !this.placeables.length || game.audio.locked ) return;
    return this._syncPositions([position], {fade: 50});
  }

  /* -------------------------------------------- */

  /**
   * Terminate playback of all ambient audio sources
   */
  stopAll() {
    this.placeables.forEach(s => s.sync(false));
  }

  /* -------------------------------------------- */

  /**
   * Get an array of listener positions for Tokens which are able to hear environmental sound.
   * @returns {Point[]}
   */
  getListenerPositions() {
    const listeners = canvas.tokens.controlled.map(token => token.center);
    if ( !listeners.length && !game.user.isGM ) {
      for ( const token of canvas.tokens.placeables ) {
        if ( token.actor?.isOwner && token.isVisible ) listeners.push(token.center);
      }
    }
    return listeners;
  }

  /* -------------------------------------------- */

  /**
   * Sync the playing state and volume of all AmbientSound objects based on the position of listener points
   * @param {Point[]} listeners     Locations of listeners which have the capability to hear
   * @param {object} [options={}]   Additional options forwarded to AmbientSound synchronization
   * @protected
   */
  _syncPositions(listeners, options) {
    if ( !this.placeables.length || game.audio.locked ) return;
    /** @type {Record<string, Partial<AmbientSoundPlaybackConfig>>} */
    const paths = {};
    for ( const /** @type {AmbientSound} */ object of this.placeables ) {
      const {path, easing, volume, walls} = object.document;
      if ( !path ) continue;
      const {sound, source} = object;

      // Track a singleton record per unique audio path
      paths[path] ||= {sound, source, object, volume: 0};
      const config = paths[path];
      if ( !config.sound && sound ) Object.assign(config, {sound, source, object}); // First defined Sound

      // Identify the closest listener to each sound source
      if ( !object.isAudible || !source.active ) continue;
      for ( let l of listeners ) {
        const v = volume * source.getVolumeMultiplier(l, {easing});
        if ( v > config.volume ) {
          Object.assign(config, {source, object, listener: l, volume: v, walls});
          config.sound ??= sound; // We might already have defined Sound
        }
      }
    }

    // Compute the effective volume for each sound path
    for ( const config of Object.values(paths) ) {
      this._configurePlayback(config);
      config.object.sync(config.volume > 0, config.volume, {...options, muffled: config.muffled});
    }
  }


  /* -------------------------------------------- */

  /**
   * Configure playback by assigning the muffled state and final playback volume for the sound.
   * This method should mutate the config object by assigning the volume and muffled properties.
   * @param {AmbientSoundPlaybackConfig} config
   * @protected
   */
  _configurePlayback(config) {
    const {source, walls} = config;

    // Inaudible sources
    if ( !config.listener ) {
      config.volume = 0;
      return;
    }

    // Muffled by walls
    if ( !walls ) {
      if ( config.listener.equals(source) ) return false; // GM users listening to the source
      const polygonCls = CONFIG.Canvas.polygonBackends.sound;
      const x = polygonCls.testCollision(config.listener, source, {mode: "first", type: "sound"});
      config.muffled = x && (x._distance < 1); // Collided before reaching the source
    }
    else config.muffled = false;
  }

  /* -------------------------------------------- */

  /**
   * Actions to take when the darkness level of the Scene is changed
   * @param {PIXI.FederatedEvent} event
   * @internal
   */
  _onDarknessChange(event) {
    const {darknessLevel, priorDarknessLevel} = event.environmentData;
    for ( const sound of this.placeables ) {
      const {min, max} = sound.document.darkness;
      if ( darknessLevel.between(min, max) === priorDarknessLevel.between(min, max) ) continue;
      sound.initializeSoundSource();
      if ( this.active ) sound.renderFlags.set({refreshState: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Play a one-shot Sound originating from a predefined point on the canvas.
   * The sound plays locally for the current client only.
   * To play a sound for all connected clients use SoundsLayer#emitAtPosition.
   *
   * @param {string} src                                  The sound source path to play
   * @param {Point} origin                                The canvas coordinates from which the sound originates
   * @param {number} radius                               The radius of effect in distance units
   * @param {object} options                              Additional options which configure playback
   * @param {number} [options.volume=1.0]                   The maximum volume at which the effect should be played
   * @param {boolean} [options.easing=true]                 Should volume be attenuated by distance?
   * @param {boolean} [options.walls=true]                  Should the sound be constrained by walls?
   * @param {boolean} [options.gmAlways=true]               Should the sound always be played for GM users regardless
   *                                                        of actively controlled tokens?
   * @param {AmbientSoundEffect} [options.baseEffect]       A base sound effect to apply to playback
   * @param {AmbientSoundEffect} [options.muffledEffect]    A muffled sound effect to apply to playback, a sound may
   *                                                        only be muffled if it is not constrained by walls
   * @param {Partial<PointSourceData>} [options.sourceData]   Additional data passed to the SoundSource constructor
   * @param {SoundPlaybackOptions} [options.playbackOptions]  Additional options passed to Sound#play
   * @returns {Promise<foundry.audio.Sound|null>}         A Promise which resolves to the played Sound, or null
   *
   * @example Play the sound of a trap springing
   * ```js
   * const src = "modules/my-module/sounds/spring-trap.ogg";
   * const origin = {x: 5200, y: 3700};  // The origin point for the sound
   * const radius = 30;                  // Audible in a 30-foot radius
   * await canvas.sounds.playAtPosition(src, origin, radius);
   * ```
   *
   * @example A Token casts a spell
   * ```js
   * const src = "modules/my-module/sounds/spells-sprite.ogg";
   * const origin = token.center;         // The origin point for the sound
   * const radius = 60;                   // Audible in a 60-foot radius
   * await canvas.sounds.playAtPosition(src, origin, radius, {
   *   walls: false,                      // Not constrained by walls with a lowpass muffled effect
   *   muffledEffect: {type: "lowpass", intensity: 6},
   *   sourceData: {
   *     angle: 120,                      // Sound emitted at a limited angle
   *     rotation: 270                    // Configure the direction of sound emission
   *   }
   *   playbackOptions: {
   *     loopStart: 12,                   // Audio sprite timing
   *     loopEnd: 16,
   *     fade: 300,                      // Fade-in 300ms
   *     onended: () => console.log("Do something after the spell sound has played")
   *   }
   * });
   * ```
   */
  async playAtPosition(src, origin, radius, {volume=1, easing=true, walls=true, gmAlways=true,
    baseEffect, muffledEffect, sourceData, playbackOptions}={}) {

    // Construct a Sound and corresponding SoundSource
    const sound = new foundry.audio.Sound(src, {context: game.audio.environment});
    const source = new CONFIG.Canvas.soundSourceClass({object: null});
    source.initialize({
      x: origin.x,
      y: origin.y,
      radius: canvas.dimensions.distancePixels * radius,
      walls,
      ...sourceData
    });
    /** @type {Partial<AmbientSoundPlaybackConfig>} */
    const config = {sound, source, listener: undefined, volume: 0, walls};

    // Identify the closest listener position
    const listeners = (gmAlways && game.user.isGM) ? [origin] : this.getListenerPositions();
    for ( const l of listeners ) {
      const v = volume * source.getVolumeMultiplier(l, {easing});
      if ( v > config.volume ) Object.assign(config, {listener: l, volume: v});
    }

    // Configure playback volume and muffled state
    this._configurePlayback(config);
    if ( !config.volume ) return null;

    // Load the Sound and apply special effects
    await sound.load();
    const sfx = CONFIG.soundEffects;
    let effect;
    if ( config.muffled && (muffledEffect?.type in sfx) ) {
      const muffledCfg = sfx[muffledEffect.type];
      effect = new muffledCfg.effectClass(sound.context, muffledEffect);
    }
    if ( !effect && (baseEffect?.type in sfx) ) {
      const baseCfg = sfx[baseEffect.type];
      effect = new baseCfg.effectClass(sound.context, baseEffect);
    }
    if ( effect ) sound.effects.push(effect);

    // Initiate sound playback
    await sound.play({...playbackOptions, loop: false, volume: config.volume});
    return sound;
  }

  /* -------------------------------------------- */

  /**
   * Emit playback to other connected clients to occur at a specified position.
   * @param {...*} args           Arguments passed to SoundsLayer#playAtPosition
   * @returns {Promise<void>}     A Promise which resolves once playback for the initiating client has completed
   */
  async emitAtPosition(...args) {
    game.socket.emit("playAudioPosition", args);
    return this.playAtPosition(...args);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle mouse cursor movements which may cause ambient audio previews to occur
   */
  _onMouseMove() {
    if ( !this.livePreview ) return;
    if ( canvas.tokens.active && canvas.tokens.controlled.length ) return;
    this.previewSound(canvas.mousePosition);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftStart(event) {
    super._onDragLeftStart(event);
    const interaction = event.interactionData;

    // Snap the origin to the grid
    if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);

    // Create a pending AmbientSoundDocument
    const cls = getDocumentClass("AmbientSound");
    const doc = new cls({type: "l", ...interaction.origin}, {parent: canvas.scene});

    // Create the preview AmbientSound object
    const sound = new this.constructor.placeableClass(doc);
    interaction.preview = this.preview.addChild(sound);
    interaction.soundState = 1;
    this.preview._creating = false;
    sound.draw();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftMove(event) {
    const {destination, soundState, preview, origin} = event.interactionData;
    if ( soundState === 0 ) return;
    const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y);
    preview.document.updateSource({radius: radius / canvas.dimensions.distancePixels});
    preview.initializeSoundSource();
    preview.renderFlags.set({refreshState: true});
    event.interactionData.soundState = 2;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftDrop(event) {
    // Snap the destination to the grid
    const interaction = event.interactionData;
    if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);
    const {soundState, destination, origin, preview} = interaction;
    if ( soundState !== 2 ) return;

    // Render the preview sheet for confirmation
    const radius = Math.hypot(destination.x - origin.x, destination.y - origin.y);
    if ( radius < (canvas.dimensions.size / 2) ) return;
    preview.document.updateSource({radius: radius / canvas.dimensions.distancePixels});
    preview.initializeSoundSource();
    preview.renderFlags.set({refreshState: true});
    preview.sheet.render(true);
    this.preview._creating = true;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftCancel(event) {
    if ( this.preview._creating ) return;
    return super._onDragLeftCancel(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle PlaylistSound document drop data.
   * @param {DragEvent} event  The drag drop event
   * @param {object} data      The dropped transfer data.
   */
  async _onDropData(event, data) {
    const playlistSound = await PlaylistSound.implementation.fromDropData(data);
    if ( !playlistSound ) return false;
    let origin;
    if ( (data.x === undefined) || (data.y === undefined) ) {
      const coords = this._canvasCoordinatesFromDrop(event, {center: false});
      if ( !coords ) return false;
      origin = {x: coords[0], y: coords[1]};
    } else {
      origin = {x: data.x, y: data.y};
    }
    if ( !event.shiftKey ) origin = this.getSnappedPoint(origin);
    if ( !canvas.dimensions.rect.contains(origin.x, origin.y) ) return false;
    const soundData = {
      path: playlistSound.path,
      volume: playlistSound.volume,
      x: origin.x,
      y: origin.y,
      radius: canvas.dimensions.distance * 2
    };
    return this._createPreview(soundData, {top: event.clientY - 20, left: event.clientX + 40});
  }
}

/**
 * This Canvas Layer provides a container for MeasuredTemplate objects.
 * @category - Canvas
 */
class TemplateLayer extends PlaceablesLayer {

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "templates",
      rotatableObjects: true,
      zIndex: 400
    });
  }

  /** @inheritdoc */
  static documentName = "MeasuredTemplate";

  /* -------------------------------------------- */

  /** @inheritdoc */
  get hookName() {
    return TemplateLayer.name;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _deactivate() {
    super._deactivate();
    this.objects.visible = true;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);
    this.objects.visible = true;
  }

  /* -------------------------------------------- */

  /**
   * Register game settings used by the TemplatesLayer
   */
  static registerSettings() {
    game.settings.register("core", "gridTemplates", {
      name: "TEMPLATE.GridTemplatesSetting",
      hint: "TEMPLATE.GridTemplatesSettingHint",
      scope: "world",
      config: true,
      type: new foundry.data.fields.BooleanField({initial: false}),
      onChange: () => {
        if ( canvas.ready ) canvas.templates.draw();
      }
    });
    game.settings.register("core", "coneTemplateType", {
      name: "TEMPLATE.ConeTypeSetting",
      hint: "TEMPLATE.ConeTypeSettingHint",
      scope: "world",
      config: true,
      type: new foundry.data.fields.StringField({required: true, blank: false, initial: "round", choices: {
        round: "TEMPLATE.ConeTypeRound",
        flat: "TEMPLATE.ConeTypeFlat"
      }}),
      onChange: () => {
        if ( canvas.ready ) canvas.templates.draw();
      }
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftStart(event) {
    super._onDragLeftStart(event);
    const interaction = event.interactionData;

    // Snap the origin to the grid
    if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);

    // Create a pending MeasuredTemplateDocument
    const tool = game.activeTool;
    const previewData = {
      user: game.user.id,
      t: tool,
      x: interaction.origin.x,
      y: interaction.origin.y,
      sort: Math.max(this.getMaxSort() + 1, 0),
      distance: 1,
      direction: 0,
      fillColor: game.user.color || "#FF0000",
      hidden: event.altKey
    };
    const defaults = CONFIG.MeasuredTemplate.defaults;
    if ( tool === "cone") previewData.angle = defaults.angle;
    else if ( tool === "ray" ) previewData.width = (defaults.width * canvas.dimensions.distance);
    const cls = getDocumentClass("MeasuredTemplate");
    const doc = new cls(previewData, {parent: canvas.scene});

    // Create a preview MeasuredTemplate object
    const template = new this.constructor.placeableClass(doc);
    interaction.preview = this.preview.addChild(template);
    template.draw();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftMove(event) {
    const interaction = event.interactionData;

    // Snap the destination to the grid
    if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);

    // Compute the ray
    const {origin, destination, preview} = interaction;
    const ray = new Ray(origin, destination);
    let distance;

    // Grid type
    if ( game.settings.get("core", "gridTemplates") ) {
      distance = canvas.grid.measurePath([origin, destination]).distance;
    }

    // Euclidean type
    else {
      const ratio = (canvas.dimensions.size / canvas.dimensions.distance);
      distance = ray.distance / ratio;
    }

    // Update the preview object
    preview.document.direction = Math.normalizeDegrees(Math.toDegrees(ray.angle));
    preview.document.distance = distance;
    preview.renderFlags.set({refreshShape: true});
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onMouseWheel(event) {

    // Determine whether we have a hovered template?
    const template = this.hover;
    if ( !template || template.isPreview ) return;

    // Determine the incremental angle of rotation from event data
    const snap = event.shiftKey ? 15 : 5;
    const delta = snap * Math.sign(event.delta);
    return template.rotate(template.document.direction + delta, snap);
  }
}

/**
 * A PlaceablesLayer designed for rendering the visual Scene for a specific vertical cross-section.
 * @category - Canvas
 */
class TilesLayer extends PlaceablesLayer {

  /** @inheritdoc */
  static documentName = "Tile";

  /* -------------------------------------------- */
  /*  Layer Attributes                            */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "tiles",
      zIndex: 300,
      controllableObjects: true,
      rotatableObjects: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get hookName() {
    return TilesLayer.name;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get hud() {
    return canvas.hud.tile;
  }

  /* -------------------------------------------- */

  /**
   * An array of Tile objects which are rendered within the objects container
   * @type {Tile[]}
   */
  get tiles() {
    return this.objects?.children || [];
  }

  /* -------------------------------------------- */

  /** @override */
  *controllableObjects() {
    const foreground = ui.controls.control.foreground ?? false;
    for ( const placeable of super.controllableObjects() ) {
      const overhead = placeable.document.elevation >= placeable.document.parent.foregroundElevation;
      if ( overhead === foreground ) yield placeable;
    }
  }

  /* -------------------------------------------- */
  /*  Layer Methods                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  getSnappedPoint(point) {
    if ( canvas.forceSnapVertices ) return canvas.grid.getSnappedPoint(point, {mode: CONST.GRID_SNAPPING_MODES.VERTEX});
    return super.getSnappedPoint(point);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    for ( const tile of this.tiles ) {
      if ( tile.isVideo ) {
        game.video.stop(tile.sourceElement);
      }
    }
    return super._tearDown(options);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftStart(event) {
    super._onDragLeftStart(event);
    const interaction = event.interactionData;

    // Snap the origin to the grid
    if ( !event.shiftKey ) interaction.origin = this.getSnappedPoint(interaction.origin);

    // Create the preview
    const tile = this.constructor.placeableClass.createPreview(interaction.origin);
    interaction.preview = this.preview.addChild(tile);
    this.preview._creating = false;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftMove(event) {
    const interaction = event.interactionData;

    // Snap the destination to the grid
    if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);

    const {destination, tilesState, preview, origin} = interaction;
    if ( tilesState === 0 ) return;

    // Determine the drag distance
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;
    const dist = Math.min(Math.abs(dx), Math.abs(dy));

    // Update the preview object
    preview.document.width = (event.altKey ? dist * Math.sign(dx) : dx);
    preview.document.height = (event.altKey ? dist * Math.sign(dy) : dy);
    preview.renderFlags.set({refreshSize: true});

    // Confirm the creation state
    interaction.tilesState = 2;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftDrop(event) {
    // Snap the destination to the grid
    const interaction = event.interactionData;
    if ( !event.shiftKey ) interaction.destination = this.getSnappedPoint(interaction.destination);

    const { tilesState, preview } = interaction;
    if ( tilesState !== 2 ) return;
    const doc = preview.document;

    // Re-normalize the dropped shape
    const r = new PIXI.Rectangle(doc.x, doc.y, doc.width, doc.height).normalize();
    preview.document.updateSource(r);

    // Require a minimum created size
    if ( Math.hypot(r.width, r.height) < (canvas.dimensions.size / 2) ) return;

    // Render the preview sheet for confirmation
    preview.sheet.render(true, {preview: true});
    this.preview._creating = true;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftCancel(event) {
    if ( this.preview._creating ) return;
    return super._onDragLeftCancel(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle drop events for Tile data on the Tiles Layer
   * @param {DragEvent} event     The concluding drag event
   * @param {object} data         The extracted Tile data
   * @private
   */
  async _onDropData(event, data) {
    if ( !data.texture?.src ) return;
    if ( !this.active ) this.activate();

    // Get the data for the tile to create
    const createData = await this._getDropData(event, data);

    // Validate that the drop position is in-bounds and snap to grid
    if ( !canvas.dimensions.rect.contains(createData.x, createData.y) ) return false;

    // Create the Tile Document
    const cls = getDocumentClass(this.constructor.documentName);
    return cls.create(createData, {parent: canvas.scene});
  }

  /* -------------------------------------------- */

  /**
   * Prepare the data object when a new Tile is dropped onto the canvas
   * @param {DragEvent} event     The concluding drag event
   * @param {object} data         The extracted Tile data
   * @returns {object}            The prepared data to create
   */
  async _getDropData(event, data) {

    // Determine the tile size
    const tex = await loadTexture(data.texture.src);
    const ratio = canvas.dimensions.size / (data.tileSize || canvas.dimensions.size);
    data.width = tex.baseTexture.width * ratio;
    data.height = tex.baseTexture.height * ratio;

    // Determine the elevation
    const foreground = ui.controls.controls.find(c => c.layer === "tiles").foreground;
    data.elevation = foreground ? canvas.scene.foregroundElevation : 0;
    data.sort = Math.max(this.getMaxSort() + 1, 0);
    foundry.utils.setProperty(data, "occlusion.mode", foreground
      ? CONST.OCCLUSION_MODES.FADE : CONST.OCCLUSION_MODES.NONE);

    // Determine the final position and snap to grid unless SHIFT is pressed
    data.x = data.x - (data.width / 2);
    data.y = data.y - (data.height / 2);
    if ( !event.shiftKey ) {
      const {x, y} = this.getSnappedPoint(data);
      data.x = x;
      data.y = y;
    }

    // Create the tile as hidden if the ALT key is pressed
    if ( event.altKey ) data.hidden = true;
    return data;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get roofs() {
    const msg = "TilesLayer#roofs has been deprecated without replacement.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.placeables.filter(t => t.isRoof);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  get textureDataMap() {
    const msg = "TilesLayer#textureDataMap has moved to TextureLoader.textureBufferDataMap";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return TextureLoader.textureBufferDataMap;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  get depthMask() {
    const msg = "TilesLayer#depthMask is deprecated without replacement. Use canvas.masks.depth instead";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return canvas.masks.depth;
  }
}

/**
 * The Tokens Container.
 * @category - Canvas
 */
class TokenLayer extends PlaceablesLayer {

  /**
   * The current index position in the tab cycle
   * @type {number|null}
   * @private
   */
  _tabIndex = null;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "tokens",
      controllableObjects: true,
      rotatableObjects: true,
      zIndex: 200
    });
  }

  /** @inheritdoc */
  static documentName = "Token";

  /* -------------------------------------------- */

  /**
   * The set of tokens that trigger occlusion (a union of {@link CONST.TOKEN_OCCLUSION_MODES}).
   * @type {number}
   */
  set occlusionMode(value) {
    this.#occlusionMode = value;
    canvas.perception.update({refreshOcclusion: true});
  }

  get occlusionMode() {
    return this.#occlusionMode;
  }

  #occlusionMode;

  /* -------------------------------------------- */

  /** @inheritdoc */
  get hookName() {
    return TokenLayer.name;
  }

  /* -------------------------------------------- */
  /*  Properties
  /* -------------------------------------------- */

  /**
   * Token objects on this layer utilize the TokenHUD
   */
  get hud() {
    return canvas.hud.token;
  }

  /**
   * An Array of tokens which belong to actors which are owned
   * @type {Token[]}
   */
  get ownedTokens() {
    return this.placeables.filter(t => t.actor && t.actor.isOwner);
  }

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /** @override */
  getSnappedPoint(point) {
    const M = CONST.GRID_SNAPPING_MODES;
    return canvas.grid.getSnappedPoint(point, {mode: M.TOP_LEFT_CORNER, resolution: 1});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);
    this.objects.visible = true;
    // Reset the Tokens layer occlusion mode for the Scene
    const M = CONST.TOKEN_OCCLUSION_MODES;
    this.#occlusionMode = game.user.isGM ? M.CONTROLLED | M.HOVERED | M.HIGHLIGHTED : M.OWNED;
    canvas.app.ticker.add(this._animateTargets, this);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    this.concludeAnimation();
    return super._tearDown(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _activate() {
    super._activate();
    if ( canvas.controls ) canvas.controls.doors.visible = true;
    this._tabIndex = null;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _deactivate() {
    super._deactivate();
    this.objects.visible = true;
    if ( canvas.controls ) canvas.controls.doors.visible = false;
  }

  /* -------------------------------------------- */

  /** @override */
  _pasteObject(copy, offset, {hidden=false, snap=true}={}) {
    const {x, y} = copy.document;
    let position = {x: x + offset.x, y: y + offset.y};
    if ( snap ) position = copy.getSnappedPosition(position);
    const d = canvas.dimensions;
    position.x = Math.clamp(position.x, 0, d.width - 1);
    position.y = Math.clamp(position.y, 0, d.height - 1);
    const data = copy.document.toObject();
    delete data._id;
    data.x = position.x;
    data.y = position.y;
    data.hidden ||= hidden;
    return data;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getMovableObjects(ids, includeLocked) {
    const ruler = canvas.controls.ruler;
    if ( ruler.state === Ruler.STATES.MEASURING ) return [];
    const tokens = super._getMovableObjects(ids, includeLocked);
    if ( ruler.token ) tokens.findSplice(token => token === ruler.token);
    return tokens;
  }

  /* -------------------------------------------- */

  /**
   * Target all Token instances which fall within a coordinate rectangle.
   *
   * @param {object} rectangle                      The selection rectangle.
   * @param {number} rectangle.x                    The top-left x-coordinate of the selection rectangle
   * @param {number} rectangle.y                    The top-left y-coordinate of the selection rectangle
   * @param {number} rectangle.width                The width of the selection rectangle
   * @param {number} rectangle.height               The height of the selection rectangle
   * @param {object} [options]                      Additional options to configure targeting behaviour.
   * @param {boolean} [options.releaseOthers=true]  Whether or not to release other targeted tokens
   * @returns {number}                              The number of Token instances which were targeted.
   */
  targetObjects({x, y, width, height}, {releaseOthers=true}={}) {
    const user = game.user;

    // Get the set of targeted tokens
    const targets = new Set();
    const rectangle = new PIXI.Rectangle(x, y, width, height);
    for ( const token of this.placeables ) {
      if ( !token.visible || token.document.isSecret ) continue;
      if ( token._overlapsSelection(rectangle) ) targets.add(token);
    }

    // Maybe release other targets
    if ( releaseOthers ) {
      for ( const token of user.targets ) {
        if ( targets.has(token) ) continue;
        token.setTarget(false, {releaseOthers: false, groupSelection: true});
      }
    }

    // Acquire targets for tokens which are not yet targeted
    for ( const token of targets ) {
      if ( user.targets.has(token) ) continue;
      token.setTarget(true, {releaseOthers: false, groupSelection: true});
    }

    // Broadcast the target change
    user.broadcastActivity({targets: user.targets.ids});

    // Return the number of targeted tokens
    return user.targets.size;
  }

  /* -------------------------------------------- */

  /**
   * Cycle the controlled token by rotating through the list of Owned Tokens that are available within the Scene
   * Tokens are currently sorted in order of their TokenID
   *
   * @param {boolean} forwards  Which direction to cycle. A truthy value cycles forward, while a false value
   *                            cycles backwards.
   * @param {boolean} reset     Restart the cycle order back at the beginning?
   * @returns {Token|null}       The Token object which was cycled to, or null
   */
  cycleTokens(forwards, reset) {
    let next = null;
    if ( reset ) this._tabIndex = null;
    const order = this._getCycleOrder();

    // If we are not tab cycling, try and jump to the currently controlled or impersonated token
    if ( this._tabIndex === null ) {
      this._tabIndex = 0;

      // Determine the ideal starting point based on controlled tokens or the primary character
      let current = this.controlled.length ? order.find(t => this.controlled.includes(t)) : null;
      if ( !current && game.user.character ) {
        const actorTokens = game.user.character.getActiveTokens();
        current = actorTokens.length ? order.find(t => actorTokens.includes(t)) : null;
      }
      current = current || order[this._tabIndex] || null;

      // Either start cycling, or cancel
      if ( !current ) return null;
      next = current;
    }

    // Otherwise, cycle forwards or backwards
    else {
      if ( forwards ) this._tabIndex = this._tabIndex < (order.length - 1) ? this._tabIndex + 1 : 0;
      else this._tabIndex = this._tabIndex > 0 ? this._tabIndex - 1 : order.length - 1;
      next = order[this._tabIndex];
      if ( !next ) return null;
    }

    // Pan to the token and control it (if possible)
    canvas.animatePan({x: next.center.x, y: next.center.y, duration: 250});
    next.control();
    return next;
  }

  /* -------------------------------------------- */

  /**
   * Get the tab cycle order for tokens by sorting observable tokens based on their distance from top-left.
   * @returns {Token[]}
   * @private
   */
  _getCycleOrder() {
    const observable = this.placeables.filter(token => {
      if ( game.user.isGM ) return true;
      if ( !token.actor?.testUserPermission(game.user, "OBSERVER") ) return false;
      return !token.document.hidden;
    });
    observable.sort((a, b) => Math.hypot(a.x, a.y) - Math.hypot(b.x, b.y));
    return observable;
  }

  /* -------------------------------------------- */

  /**
   * Immediately conclude the animation of any/all tokens
   */
  concludeAnimation() {
    this.placeables.forEach(t => t.stopAnimation());
    canvas.app.ticker.remove(this._animateTargets, this);
  }

  /* -------------------------------------------- */

  /**
   * Animate targeting arrows on targeted tokens.
   * @private
   */
  _animateTargets() {
    if ( !game.user.targets.size ) return;
    if ( this._t === undefined ) this._t = 0;
    else this._t += canvas.app.ticker.elapsedMS;
    const duration = 2000;
    const pause = duration * .6;
    const fade = (duration - pause) * .25;
    const minM = .5; // Minimum margin is half the size of the arrow.
    const maxM = 1; // Maximum margin is the full size of the arrow.
    // The animation starts with the arrows halfway across the token bounds, then move fully inside the bounds.
    const rm = maxM - minM;
    const t = this._t % duration;
    let dt = Math.max(0, t - pause) / (duration - pause);
    dt = CanvasAnimation.easeOutCircle(dt);
    const m = t < pause ? minM : minM + (rm * dt);
    const ta = Math.max(0, t - duration + fade);
    const a = 1 - (ta / fade);

    for ( const t of game.user.targets ) {
      t._refreshTarget({
        margin: m,
        alpha: a,
        color: CONFIG.Canvas.targeting.color,
        size: CONFIG.Canvas.targeting.size
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Provide an array of Tokens which are eligible subjects for tile occlusion.
   * By default, only tokens which are currently controlled or owned by a player are included as subjects.
   * @returns {Token[]}
   * @protected
   * @internal
   */
  _getOccludableTokens() {
    const M = CONST.TOKEN_OCCLUSION_MODES;
    const mode = this.occlusionMode;
    if ( (mode & M.VISIBLE) || ((mode & M.HIGHLIGHTED) && this.highlightObjects) ) {
      return this.placeables.filter(t => t.visible);
    }
    const tokens = new Set();
    if ( (mode & M.HOVERED) && this.hover ) tokens.add(this.hover);
    if ( mode & M.CONTROLLED ) this.controlled.forEach(t => tokens.add(t));
    if ( mode & M.OWNED ) this.ownedTokens.filter(t => !t.document.hidden).forEach(t => tokens.add(t));
    return Array.from(tokens);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  storeHistory(type, data) {
    super.storeHistory(type, type === "update" ? data.map(d => {
      // Clean actorData and delta updates from the history so changes to those fields are not undone.
      d = foundry.utils.deepClone(d);
      delete d.actorData;
      delete d.delta;
      delete d._regions;
      return d;
    }) : data);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle dropping of Actor data onto the Scene canvas
   * @private
   */
  async _onDropActorData(event, data) {

    // Ensure the user has permission to drop the actor and create a Token
    if ( !game.user.can("TOKEN_CREATE") ) {
      return ui.notifications.warn("You do not have permission to create new Tokens!");
    }

    // Acquire dropped data and import the actor
    let actor = await Actor.implementation.fromDropData(data);
    if ( !actor.isOwner ) {
      return ui.notifications.warn(`You do not have permission to create a new Token for the ${actor.name} Actor.`);
    }
    if ( actor.compendium ) {
      const actorData = game.actors.fromCompendium(actor);
      actor = await Actor.implementation.create(actorData, {fromCompendium: true});
    }

    // Prepare the Token document
    const td = await actor.getTokenDocument({
      hidden: game.user.isGM && event.altKey,
      sort: Math.max(this.getMaxSort() + 1, 0)
    }, {parent: canvas.scene});

    // Set the position of the Token such that its center point is the drop position before snapping
    const t = this.createObject(td);
    let position = t.getCenterPoint({x: 0, y: 0});
    position.x = data.x - position.x;
    position.y = data.y - position.y;
    if ( !event.shiftKey ) position = t.getSnappedPosition(position);
    t.destroy({children: true});
    td.updateSource(position);

    // Validate the final position
    if ( !canvas.dimensions.rect.contains(td.x, td.y) ) return false;

    // Submit the Token creation request and activate the Tokens layer (if not already active)
    this.activate();
    return td.constructor.create(td, {parent: canvas.scene});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickLeft(event) {
    let tool = game.activeTool;

    // If Control is being held, we always want the Tool to be Ruler
    if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) tool = "ruler";
    switch ( tool ) {
      // Clear targets if Left Click Release is set
      case "target":
        if ( game.settings.get("core", "leftClickRelease") ) {
          game.user.updateTokenTargets([]);
          game.user.broadcastActivity({targets: []});
        }
        break;

      // Place Ruler waypoints
      case "ruler":
        return canvas.controls.ruler._onClickLeft(event);
    }

    // If we don't explicitly return from handling the tool, use the default behavior
    super._onClickLeft(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onMouseWheel(event) {

    // Prevent wheel rotation during dragging
    if ( this.preview.children.length ) return;

    // Determine the incremental angle of rotation from event data
    const snap = canvas.grid.isHexagonal ? (event.shiftKey ? 60 : 30) : (event.shiftKey ? 45 : 15);
    const delta = snap * Math.sign(event.delta);
    return this.rotateMany({delta, snap});
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get gridPrecision() {
    // eslint-disable-next-line no-unused-expressions
    super.gridPrecision;
    return 1; // Snap tokens to top-left
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  async toggleCombat(state=true, combat=null, {token=null}={}) {
    foundry.utils.logCompatibilityWarning("TokenLayer#toggleCombat is deprecated in favor of"
      + " TokenDocument.implementation.createCombatants and TokenDocument.implementation.deleteCombatants", {since: 12, until: 14});
    const tokens = this.controlled.map(t => t.document);
    if ( token && !token.controlled && (token.inCombat !== state) ) tokens.push(token.document);
    if ( state ) return TokenDocument.implementation.createCombatants(tokens, {combat});
    else return TokenDocument.implementation.deleteCombatants(tokens, {combat});
  }
}

/**
 * The Walls canvas layer which provides a container for Wall objects within the rendered Scene.
 * @category - Canvas
 */
class WallsLayer extends PlaceablesLayer {

  /**
   * A graphics layer used to display chained Wall selection
   * @type {PIXI.Graphics}
   */
  chain = null;

  /**
   * Track whether we are currently within a chained placement workflow
   * @type {boolean}
   */
  _chain = false;

  /**
   * Track the most recently created or updated wall data for use with the clone tool
   * @type {Object|null}
   * @private
   */
  _cloneType = null;

  /**
   * Reference the last interacted wall endpoint for the purposes of chaining
   * @type {{point: PointArray}}
   * @private
   */
  last = {
    point: null
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static get layerOptions() {
    return foundry.utils.mergeObject(super.layerOptions, {
      name: "walls",
      controllableObjects: true,
      zIndex: 700
    });
  }

  /** @inheritdoc */
  static documentName = "Wall";

  /* -------------------------------------------- */

  /** @inheritdoc */
  get hookName() {
    return WallsLayer.name;
  }

  /* -------------------------------------------- */

  /**
   * The grid used for snapping.
   * It's the same as canvas.grid except in the gridless case where this is the square version of the gridless grid.
   * @type {BaseGrid}
   */
  #grid = canvas.grid;

  /* -------------------------------------------- */

  /**
   * An Array of Wall instances in the current Scene which act as Doors.
   * @type {Wall[]}
   */
  get doors() {
    return this.objects.children.filter(w => w.document.door > CONST.WALL_DOOR_TYPES.NONE);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @override */
  getSnappedPoint(point) {
    const M = CONST.GRID_SNAPPING_MODES;
    const size = canvas.dimensions.size;
    return this.#grid.getSnappedPoint(point, canvas.forceSnapVertices ? {mode: M.VERTEX} : {
      mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT,
      resolution: size >= 128 ? 8 : (size >= 64 ? 4 : 2)
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    this.#grid = canvas.grid.isGridless ? new foundry.grid.SquareGrid({size: canvas.grid.size}) : canvas.grid;
    await super._draw(options);
    this.chain = this.addChildAt(new PIXI.Graphics(), 0);
    this.last = {point: null};
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _deactivate() {
    super._deactivate();
    this.chain?.clear();
  }

  /* -------------------------------------------- */

  /**
   * Given a point and the coordinates of a wall, determine which endpoint is closer to the point
   * @param {Point} point         The origin point of the new Wall placement
   * @param {Wall} wall           The existing Wall object being chained to
   * @returns {PointArray}        The [x,y] coordinates of the starting endpoint
   */
  static getClosestEndpoint(point, wall) {
    const c = wall.coords;
    const a = [c[0], c[1]];
    const b = [c[2], c[3]];

    // Exact matches
    if ( a.equals([point.x, point.y]) ) return a;
    else if ( b.equals([point.x, point.y]) ) return b;

    // Closest match
    const da = Math.hypot(point.x - a[0], point.y - a[1]);
    const db = Math.hypot(point.x - b[0], point.y - b[1]);
    return da < db ? a : b;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  releaseAll(options) {
    if ( this.chain ) this.chain.clear();
    return super.releaseAll(options);
  }

  /* -------------------------------------------- */

  /** @override */
  _pasteObject(copy, offset, options) {
    const c = copy.document.c;
    const dx = Math.round(offset.x);
    const dy = Math.round(offset.y);
    const a = {x: c[0] + dx, y: c[1] + dy};
    const b = {x: c[2] + dx, y: c[3] + dy};
    const data = copy.document.toObject();
    delete data._id;
    data.c = [a.x, a.y, b.x, b.y];
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Pan the canvas view when the cursor position gets close to the edge of the frame
   * @param {MouseEvent} event    The originating mouse movement event
   * @param {number} x            The x-coordinate
   * @param {number} y            The y-coordinate
   * @private
   */
  _panCanvasEdge(event, x, y) {

    // Throttle panning by 20ms
    const now = Date.now();
    if ( now - (event.interactionData.panTime || 0) <= 100 ) return;
    event.interactionData.panTime = now;

    // Determine the amount of shifting required
    const pad = 50;
    const shift = 500 / canvas.stage.scale.x;

    // Shift horizontally
    let dx = 0;
    if ( x < pad ) dx = -shift;
    else if ( x > window.innerWidth - pad ) dx = shift;

    // Shift vertically
    let dy = 0;
    if ( y < pad ) dy = -shift;
    else if ( y > window.innerHeight - pad ) dy = shift;

    // Enact panning
    if (( dx || dy ) && !this._panning ) {
      return canvas.animatePan({x: canvas.stage.pivot.x + dx, y: canvas.stage.pivot.y + dy, duration: 100});
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the wall endpoint coordinates for a given point.
   * @param {Point} point                    The candidate wall endpoint.
   * @param {object} [options]
   * @param {boolean} [options.snap=true]    Snap to the grid?
   * @returns {[x: number, y: number]}       The wall endpoint coordinates.
   * @internal
   */
  _getWallEndpointCoordinates(point, {snap=true}={}) {
    if ( snap ) point = this.getSnappedPoint(point);
    return [point.x, point.y].map(Math.round);
  }

  /* -------------------------------------------- */

  /**
   * The Scene Controls tools provide several different types of prototypical Walls to choose from
   * This method helps to translate each tool into a default wall data configuration for that type
   * @param {string} tool     The active canvas tool
   * @private
   */
  _getWallDataFromActiveTool(tool) {

    // Using the clone tool
    if ( tool === "clone" && this._cloneType ) return this._cloneType;

    // Default wall data
    const wallData = {
      light: CONST.WALL_SENSE_TYPES.NORMAL,
      sight: CONST.WALL_SENSE_TYPES.NORMAL,
      sound: CONST.WALL_SENSE_TYPES.NORMAL,
      move: CONST.WALL_SENSE_TYPES.NORMAL
    };

    // Tool-based wall restriction types
    switch ( tool ) {
      case "invisible":
        wallData.sight = wallData.light = wallData.sound = CONST.WALL_SENSE_TYPES.NONE; break;
      case "terrain":
        wallData.sight = wallData.light = wallData.sound = CONST.WALL_SENSE_TYPES.LIMITED; break;
      case "ethereal":
        wallData.move = wallData.sound = CONST.WALL_SENSE_TYPES.NONE; break;
      case "doors":
        wallData.door = CONST.WALL_DOOR_TYPES.DOOR; break;
      case "secret":
        wallData.door = CONST.WALL_DOOR_TYPES.SECRET; break;
      case "window":
        const d = canvas.dimensions.distance;
        wallData.sight = wallData.light = CONST.WALL_SENSE_TYPES.PROXIMITY;
        wallData.threshold = {light: 2 * d, sight: 2 * d, attenuation: true};
        break;
    }
    return wallData;
  }

  /* -------------------------------------------- */

  /**
   * Identify the interior enclosed by the given walls.
   * @param {Wall[]} walls        The walls that enclose the interior.
   * @returns {PIXI.Polygon[]}    The polygons of the interior.
   * @license MIT
   */
  identifyInteriorArea(walls) {

    // Build the graph from the walls
    const vertices = new Map();
    const addEdge = (a, b) => {
      let v = vertices.get(a.key);
      if ( !v ) vertices.set(a.key, v = {X: a.x, Y: a.y, key: a.key, neighbors: new Set(), visited: false});
      let w = vertices.get(b.key);
      if ( !w ) vertices.set(b.key, w = {X: b.x, Y: b.y, key: b.key, neighbors: new Set(), visited: false});
      if ( v !== w ) {
        v.neighbors.add(w);
        w.neighbors.add(v);
      }
    };
    for ( const wall of walls ) {
      const edge = wall.edge;
      const a = new foundry.canvas.edges.PolygonVertex(edge.a.x, edge.a.y);
      const b = new foundry.canvas.edges.PolygonVertex(edge.b.x, edge.b.y);
      if ( a.key === b.key ) continue;
      if ( edge.intersections.length === 0 ) addEdge(a, b);
      else {
        const p = edge.intersections.map(i => foundry.canvas.edges.PolygonVertex.fromPoint(i.intersection));
        p.push(a, b);
        p.sort((v, w) => (v.x - w.x) || (v.y - w.y));
        for ( let k = 1; k < p.length; k++ ) {
          const a = p[k - 1];
          const b = p[k];
          if ( a.key === b.key ) continue;
          addEdge(a, b);
        }
      }
    }

    // Find the boundary paths of the interior that enclosed by the walls
    const paths = [];
    while ( vertices.size !== 0 ) {
      let start;
      for ( const vertex of vertices.values() ) {
        vertex.visited = false;
        if ( !start || (start.X > vertex.X) || ((start.X === vertex.X) && (start.Y > vertex.Y)) ) start = vertex;
      }
      if ( start.neighbors.size >= 2 ) {
        const path = [];
        let current = start;
        let previous = {X: current.X - 1, Y: current.Y - 1};
        for ( ;; ) {
          current.visited = true;
          const x0 = previous.X;
          const y0 = previous.Y;
          const x1 = current.X;
          const y1 = current.Y;
          let next;
          for ( const vertex of current.neighbors ) {
            if ( vertex === previous ) continue;
            if ( (vertex !== start) && vertex.visited ) continue;
            if ( !next ) {
              next = vertex;
              continue;
            }
            const x2 = next.X;
            const y2 = next.Y;
            const a1 = ((y0 - y1) * (x2 - x1)) + ((x1 - x0) * (y2 - y1));
            const x3 = vertex.X;
            const y3 = vertex.Y;
            const a2 = ((y0 - y1) * (x3 - x1)) + ((x1 - x0) * (y3 - y1));
            if ( a1 < 0 ) {
              if ( a2 >= 0 ) continue;
            } else if ( a1 > 0 ) {
              if ( a2 < 0 ) {
                next = vertex;
                continue;
              }
              if ( a2 === 0 ) {
                const b2 = ((x3 - x1) * (x0 - x1)) + ((y3 - y1) * (y0 - y1)) > 0;
                if ( !b2 ) next = vertex;
                continue;
              }
            } else {
              if ( a2 < 0 ) {
                next = vertex;
                continue;
              }
              const b1 = ((x2 - x1) * (x0 - x1)) + ((y2 - y1) * (y0 - y1)) > 0;
              if ( a2 > 0) {
                if ( b1 ) next = vertex;
                continue;
              }
              const b2 = ((x3 - x1) * (x0 - x1)) + ((y3 - y1) * (y0 - y1)) > 0;
              if ( b1 && !b2 ) next = vertex;
              continue;
            }
            const c = ((y1 - y2) * (x3 - x1)) + ((x2 - x1) * (y3 - y1));
            if ( c > 0 ) continue;
            if ( c < 0 ) {
              next = vertex;
              continue;
            }
            const d1 = ((x2 - x1) * (x2 - x1)) + ((y2 - y1) * (y2 - y1));
            const d2 = ((x3 - x1) * (x3 - x1)) + ((y3 - y1) * (y3 - y1));
            if ( d2 < d1 ) next = vertex;
          }
          if (next) {
            path.push(current);
            previous = current;
            current = next;
            if ( current === start ) break;
          } else {
            current = path.pop();
            if ( !current ) {
              previous = undefined;
              break;
            }
            previous = path.length ? path[path.length - 1] : {X: current.X - 1, Y: current.Y - 1};
          }
        }
        if ( path.length !== 0 ) {
          paths.push(path);
          previous = path[path.length - 1];
          for ( const vertex of path ) {
            previous.neighbors.delete(vertex);
            if ( previous.neighbors.size === 0 ) vertices.delete(previous.key);
            vertex.neighbors.delete(previous);
            previous = vertex;
          }
          if ( previous.neighbors.size === 0 ) vertices.delete(previous.key);
        }
      }
      for ( const vertex of start.neighbors ) {
        vertex.neighbors.delete(start);
        if ( vertex.neighbors.size === 0 ) vertices.delete(vertex.key);
      }
      vertices.delete(start.key);
    }

    // Unionize the paths
    const clipper = new ClipperLib.Clipper();
    clipper.AddPaths(paths, ClipperLib.PolyType.ptSubject, true);
    clipper.Execute(ClipperLib.ClipType.ctUnion, paths, ClipperLib.PolyFillType.pftPositive,
      ClipperLib.PolyFillType.pftEvenOdd);

    // Convert the paths to polygons
    return paths.map(path => {
      const points = [];
      for ( const point of path ) points.push(point.X, point.Y);
      return new PIXI.Polygon(points);
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftStart(event) {
    this.clearPreviewContainer();
    const interaction = event.interactionData;
    const origin = interaction.origin;
    interaction.wallsState = WallsLayer.CREATION_STATES.NONE;
    interaction.clearPreviewContainer = true;

    // Create a pending WallDocument
    const data = this._getWallDataFromActiveTool(game.activeTool);
    const snap = !event.shiftKey;
    const isChain = this._chain || game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
    const pt = (isChain && this.last.point) ? this.last.point : this._getWallEndpointCoordinates(origin, {snap});
    data.c = pt.concat(pt);
    const cls = getDocumentClass("Wall");
    const doc = new cls(data, {parent: canvas.scene});

    // Create the preview Wall object
    const wall = new this.constructor.placeableClass(doc);
    interaction.wallsState = WallsLayer.CREATION_STATES.POTENTIAL;
    interaction.preview = wall;
    return wall.draw();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftMove(event) {
    const interaction = event.interactionData;
    const {preview, destination} = interaction;
    const states = WallsLayer.CREATION_STATES;
    if ( !preview || preview._destroyed
      || [states.NONE, states.COMPLETED].includes(interaction.wallsState) ) return;
    if ( preview.parent === null ) this.preview.addChild(preview); // Should happen the first time it is moved
    const snap = !event.shiftKey;
    preview.document.updateSource({
      c: preview.document.c.slice(0, 2).concat(this._getWallEndpointCoordinates(destination, {snap}))
    });
    preview.refresh();
    interaction.wallsState = WallsLayer.CREATION_STATES.CONFIRMED;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftDrop(event) {
    const interaction = event.interactionData;
    const {wallsState, destination, preview} = interaction;
    const states = WallsLayer.CREATION_STATES;

    // Check preview and state
    if ( !preview || preview._destroyed || (interaction.wallsState === states.NONE) ) {
      return;
    }

    // Prevent default to allow chaining to continue
    if ( game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL) ) {
      event.preventDefault();
      this._chain = true;
      if ( wallsState < WallsLayer.CREATION_STATES.CONFIRMED ) return;
    } else this._chain = false;

    // Successful wall completion
    if ( wallsState === WallsLayer.CREATION_STATES.CONFIRMED ) {
      interaction.wallsState = WallsLayer.CREATION_STATES.COMPLETED;

      // Get final endpoint location
      const snap = !event.shiftKey;
      let dest = this._getWallEndpointCoordinates(destination, {snap});
      const coords = preview.document.c.slice(0, 2).concat(dest);
      preview.document.updateSource({c: coords});

      const clearPreviewAndChain = () => {
        this.clearPreviewContainer();

        // Maybe chain
        if ( this._chain ) {
          interaction.origin = {x: dest[0], y: dest[1]};
          this._onDragLeftStart(event);
        }
      };

      // Ignore walls which are collapsed
      if ( (coords[0] === coords[2]) && (coords[1] === coords[3]) ) {
        clearPreviewAndChain();
        return;
      }

      interaction.clearPreviewContainer = false;

      // Create the Wall
      this.last = {point: dest};
      const cls = getDocumentClass(this.constructor.documentName);
      cls.create(preview.document.toObject(), {parent: canvas.scene}).finally(clearPreviewAndChain);
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftCancel(event) {
    this._chain = false;
    this.last = {point: null};
    super._onDragLeftCancel(event);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onClickRight(event) {
    if ( event.interactionData.wallsState > WallsLayer.CREATION_STATES.NONE ) return this._onDragLeftCancel(event);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  checkCollision(ray, options={}) {
    const msg = "WallsLayer#checkCollision is obsolete."
      + "Prefer calls to testCollision from CONFIG.Canvas.polygonBackends[type]";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return CONFIG.Canvas.losBackend.testCollision(ray.A, ray.B, options);
  }

  /**
   * @deprecated since v11
   * @ignore
   */
  highlightControlledSegments() {
    foundry.utils.logCompatibilityWarning("The WallsLayer#highlightControlledSegments function is deprecated in favor"
      + "of calling wall.renderFlags.set(\"refreshHighlight\") on individual Wall objects", {since: 11, until: 13});
    for ( const w of this.placeables ) w.renderFlags.set({refreshHighlight: true});
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  initialize() {
    foundry.utils.logCompatibilityWarning("WallsLayer#initialize is deprecated in favor of Canvas#edges#initialize",
      {since: 12, until: 14});
    return canvas.edges.initialize();
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  identifyInteriorWalls() {
    foundry.utils.logCompatibilityWarning("WallsLayer#identifyInteriorWalls has been deprecated. "
      + "It has no effect anymore and there's no replacement.", {since: 12, until: 14});
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  identifyWallIntersections() {
    foundry.utils.logCompatibilityWarning("WallsLayer#identifyWallIntersections is deprecated in favor of"
      + " foundry.canvas.edges.Edge.identifyEdgeIntersections and has no effect.", {since: 12, until: 14});
  }
}


/**
 * A batch shader generator that could handle extra uniforms during initialization.
 * @param {string} vertexSrc              The vertex shader source
 * @param {string} fragTemplate           The fragment shader source template
 * @param {object | (maxTextures: number) => object} [uniforms]    Additional uniforms
 */
class BatchShaderGenerator extends PIXI.BatchShaderGenerator {
  constructor(vertexSrc, fragTemplate, uniforms={}) {
    super(vertexSrc, fragTemplate);
    this.#uniforms = uniforms;
  }

  /**
   * Extra uniforms used to create the batch shader.
   * @type {object | (maxTextures: number) => object}
   */
  #uniforms;

  /* -------------------------------------------- */

  /** @override */
  generateShader(maxTextures) {
    if ( !this.programCache[maxTextures] ) {
      const sampleValues = Int32Array.from({length: maxTextures}, (n, i) => i);
      this.defaultGroupCache[maxTextures] = PIXI.UniformGroup.from({uSamplers: sampleValues}, true);
      let fragmentSrc = this.fragTemplate;
      fragmentSrc = fragmentSrc.replace(/%count%/gi, `${maxTextures}`);
      fragmentSrc = fragmentSrc.replace(/%forloop%/gi, this.generateSampleSrc(maxTextures));
      this.programCache[maxTextures] = new PIXI.Program(this.vertexSrc, fragmentSrc);
    }
    let uniforms = this.#uniforms;
    if ( typeof uniforms === "function" ) uniforms = uniforms.call(this, maxTextures);
    else uniforms = foundry.utils.deepClone(uniforms);
    return new PIXI.Shader(this.programCache[maxTextures], {
      ...uniforms,
      tint: new Float32Array([1, 1, 1, 1]),
      translationMatrix: new PIXI.Matrix(),
      default: this.defaultGroupCache[maxTextures]
    });
  }
}

/**
 * A batch renderer with a customizable data transfer function to packed geometries.
 * @extends PIXI.AbstractBatchRenderer
 */
class BatchRenderer extends PIXI.BatchRenderer {

  /**
   * The batch shader generator class.
   * @type {typeof BatchShaderGenerator}
   */
  static shaderGeneratorClass = BatchShaderGenerator;

  /* -------------------------------------------- */

  /**
   * The default uniform values for the batch shader.
   * @type {object | (maxTextures: number) => object}
   */
  static defaultUniforms = {};

  /* -------------------------------------------- */

  /**
   * The PackInterleavedGeometry function provided by the sampler.
   * @type {Function|undefined}
   * @protected
   */
  _packInterleavedGeometry;

  /* -------------------------------------------- */

  /**
   * The update function provided by the sampler and that is called just before a flush.
   * @type {(batchRenderer: BatchRenderer) => void | undefined}
   * @protected
   */
  _preRenderBatch;

  /* -------------------------------------------- */

  /**
   * Get the uniforms bound to this abstract batch renderer.
   * @returns {object|undefined}
   */
  get uniforms() {
    return this._shader?.uniforms;
  }

  /* -------------------------------------------- */

  /**
   * The number of reserved texture units that the shader generator should not use (maximum 4).
   * @param {number} val
   * @protected
   */
  set reservedTextureUnits(val) {
    // Some checks before...
    if ( typeof val !== "number" ) {
      throw new Error("BatchRenderer#reservedTextureUnits must be a number!");
    }
    if ( (val < 0) || (val > 4) ) {
      throw new Error("BatchRenderer#reservedTextureUnits must be positive and can't exceed 4.");
    }
    this.#reservedTextureUnits = val;
  }

  /**
   * Number of reserved texture units reserved by the batch shader that cannot be used by the batch renderer.
   * @returns {number}
   */
  get reservedTextureUnits() {
    return this.#reservedTextureUnits;
  }

  #reservedTextureUnits = 0;

  /* -------------------------------------------- */

  /** @override */
  setShaderGenerator({
    vertex=this.constructor.defaultVertexSrc,
    fragment=this.constructor.defaultFragmentTemplate,
    uniforms=this.constructor.defaultUniforms
  }={}) {
    this.shaderGenerator = new this.constructor.shaderGeneratorClass(vertex, fragment, uniforms);
  }

  /* -------------------------------------------- */

  /**
   * This override allows to allocate a given number of texture units reserved for a custom batched shader.
   * These reserved texture units won't be used to batch textures for PIXI.Sprite or SpriteMesh.
   * @override
   */
  contextChange() {
    const gl = this.renderer.gl;

    // First handle legacy environment
    if ( PIXI.settings.PREFER_ENV === PIXI.ENV.WEBGL_LEGACY ) this.maxTextures = 1;
    else
    {
      // Step 1: first check max texture units the GPU can handle
      const gpuMaxTex = Math.min(gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS), 65536);

      // Step 2: Remove the number of reserved texture units that could be used by a custom batch shader
      const batchMaxTex = gpuMaxTex - this.#reservedTextureUnits;

      // Step 3: Checking if remainder of texture units is at least 1. Should never happens on GPU < than 20 years old!
      if ( batchMaxTex < 1 ) {
        const msg = "Impossible to allocate the required number of texture units in contextChange#BatchRenderer. "
          + "Your GPU should handle at least 8 texture units. Currently, it is supporting: "
          + `${gpuMaxTex} texture units.`;
        throw new Error(msg);
      }

      // Step 4: Check with the maximum number of textures of the setting (webGL specifications)
      this.maxTextures = Math.min(batchMaxTex, PIXI.settings.SPRITE_MAX_TEXTURES);

      // Step 5: Check the maximum number of if statements the shader can have too..
      this.maxTextures = PIXI.checkMaxIfStatementsInShader(this.maxTextures, gl);
    }

    // Generate the batched shader
    this._shader = this.shaderGenerator?.generateShader(this.maxTextures) ?? null;

    // Initialize packed geometries
    for ( let i = 0; i < this._packedGeometryPoolSize; i++ ) {
      this._packedGeometries[i] = new (this.geometryClass)();
    }
    this.initFlushBuffers();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  onPrerender() {
    if ( !this.shaderGenerator ) this.setShaderGenerator();
    this._shader ??= this.shaderGenerator.generateShader(this.maxTextures);
    super.onPrerender();
  }

  /* -------------------------------------------- */

  /** @override */
  start() {
    this._preRenderBatch?.(this);
    super.start();
  }

  /* -------------------------------------------- */

  /** @override */
  packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex) {
    // If we have a specific function to pack data into geometry, we call it
    if ( this._packInterleavedGeometry ) {
      this._packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex);
      return;
    }
    // Otherwise, we call the parent method, with the classic packing
    super.packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex);
  }

  /* -------------------------------------------- */

  /**
   * Verify if a PIXI plugin exists. Check by name.
   * @param {string} name       The name of the pixi plugin to check.
   * @returns {boolean}         True if the plugin exists, false otherwise.
   */
  static hasPlugin(name) {
    return Object.keys(PIXI.Renderer.__plugins).some(k => k === name);
  }
}


/**
 * A mixin which decorates a PIXI.Filter or PIXI.Shader with common properties.
 * @category - Mixins
 * @param {typeof PIXI.Shader} ShaderClass   The parent ShaderClass class being mixed.
 * @returns {typeof BaseShaderMixin}         A Shader/Filter subclass mixed with BaseShaderMixin features.
 * @mixin
 */
const BaseShaderMixin = ShaderClass => {
  class BaseShaderMixin extends ShaderClass {

    /**
     * Useful constant values computed at compile time
     * @type {string}
     */
    static CONSTANTS = `
    const float PI = 3.141592653589793;
    const float TWOPI = 6.283185307179586;
    const float INVPI = 0.3183098861837907;
    const float INVTWOPI = 0.15915494309189535;
    const float SQRT2 = 1.4142135623730951;
    const float SQRT1_2 = 0.7071067811865476;
    const float SQRT3 = 1.7320508075688772;
    const float SQRT1_3 = 0.5773502691896257;
    const vec3 BT709 = vec3(0.2126, 0.7152, 0.0722);
    `;

    /* -------------------------------------------- */

    /**
     * Fast approximate perceived brightness computation
     * Using Digital ITU BT.709 : Exact luminance factors
     * @type {string}
     */
    static PERCEIVED_BRIGHTNESS = `
    float perceivedBrightness(in vec3 color) { return sqrt(dot(BT709, color * color)); }
    float perceivedBrightness(in vec4 color) { return perceivedBrightness(color.rgb); }
    float reversePerceivedBrightness(in vec3 color) { return 1.0 - perceivedBrightness(color); }
    float reversePerceivedBrightness(in vec4 color) { return 1.0 - perceivedBrightness(color.rgb); }
    `;

    /* -------------------------------------------- */

    /**
     * Convertion functions for sRGB and Linear RGB.
     * @type {string}
     */
    static COLOR_SPACES = `
    float luminance(in vec3 c) { return dot(BT709, c); }
    vec3 linear2grey(in vec3 c) { return vec3(luminance(c)); }
    
    vec3 linear2srgb(in vec3 c) {
      vec3 a = 12.92 * c;
      vec3 b = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
      vec3 s = step(vec3(0.0031308), c);
      return mix(a, b, s);
    }

    vec3 srgb2linear(in vec3 c) {
      vec3 a = c / 12.92;
      vec3 b = pow((c + 0.055) / 1.055, vec3(2.4));
      vec3 s = step(vec3(0.04045), c);
      return mix(a, b, s);
    }
    
    vec3 srgb2linearFast(in vec3 c) { return c * c; }
    vec3 linear2srgbFast(in vec3 c) { return sqrt(c); }
    
    vec3 colorClamp(in vec3 c) { return clamp(c, vec3(0.0), vec3(1.0)); }
    vec4 colorClamp(in vec4 c) { return clamp(c, vec4(0.0), vec4(1.0)); }
    
    vec3 tintColorLinear(in vec3 color, in vec3 tint, in float intensity) {
      float t = luminance(tint);
      float c = luminance(color);
      return mix(color, mix(
                            mix(tint, vec3(1.0), (c - t) / (1.0 - t)),
                            tint * (c / t),
                            step(c, t)
                           ), intensity);
    }
    
    vec3 tintColor(in vec3 color, in vec3 tint, in float intensity) {
      return linear2srgbFast(tintColorLinear(srgb2linearFast(color), srgb2linearFast(tint), intensity));
    }
    `;

    /* -------------------------------------------- */

    /**
     * Fractional Brownian Motion for a given number of octaves
     * @param {number} [octaves=4]
     * @param {number} [amp=1.0]
     * @returns {string}
     */
    static FBM(octaves = 4, amp = 1.0) {
      return `float fbm(in vec2 uv) {
        float total = 0.0, amp = ${amp.toFixed(1)};
        for (int i = 0; i < ${octaves}; i++) {
          total += noise(uv) * amp;
          uv += uv;
          amp *= 0.5;
        }
        return total;
      }`;
    }

    /* -------------------------------------------- */

    /**
     * High Quality Fractional Brownian Motion
     * @param {number} [octaves=3]
     * @returns {string}
     */
    static FBMHQ(octaves = 3) {
      return `float fbm(in vec2 uv, in float smoothness) {   
        float s = exp2(-smoothness);
        float f = 1.0;
        float a = 1.0;
        float t = 0.0;
        for( int i = 0; i < ${octaves}; i++ ) {
            t += a * noise(f * uv);
            f *= 2.0;
            a *= s;
        }
        return t;
      }`;
    }

    /* -------------------------------------------- */

    /**
     * Angular constraint working with coordinates on the range [-1, 1]
     * => coord: Coordinates
     * => angle: Angle in radians
     * => smoothness: Smoothness of the pie
     * => l: Length of the pie.
     * @type {string}
     */
    static PIE = `
    float pie(in vec2 coord, in float angle, in float smoothness, in float l) {   
      coord.x = abs(coord.x);
      vec2 va = vec2(sin(angle), cos(angle));
      float lg = length(coord) - l;
      float clg = length(coord - va * clamp(dot(coord, va) , 0.0, l));
      return smoothstep(0.0, smoothness, max(lg, clg * sign(va.y * coord.x - va.x * coord.y)));
    }`;

    /* -------------------------------------------- */

    /**
     * A conventional pseudo-random number generator with the "golden" numbers, based on uv position
     * @type {string}
     */
    static PRNG_LEGACY = `
    float random(in vec2 uv) { 
      return fract(cos(dot(uv, vec2(12.9898, 4.1414))) * 43758.5453);
    }`;

    /* -------------------------------------------- */

    /**
     * A pseudo-random number generator based on uv position which does not use cos/sin
     * This PRNG replaces the old PRNG_LEGACY to workaround some driver bugs
     * @type {string}
     */
    static PRNG = `
    float random(in vec2 uv) { 
      uv = mod(uv, 1000.0);
      return fract( dot(uv, vec2(5.23, 2.89) 
                        * fract((2.41 * uv.x + 2.27 * uv.y)
                                 * 251.19)) * 551.83);
    }`;

    /* -------------------------------------------- */

    /**
     * A Vec2 pseudo-random generator, based on uv position
     * @type {string}
     */
    static PRNG2D = `
    vec2 random(in vec2 uv) {
      vec2 uvf = fract(uv * vec2(0.1031, 0.1030));
      uvf += dot(uvf, uvf.yx + 19.19);
      return fract((uvf.x + uvf.y) * uvf);
    }`;

    /* -------------------------------------------- */

    /**
     * A Vec3 pseudo-random generator, based on uv position
     * @type {string}
     */
    static PRNG3D = `
    vec3 random(in vec3 uv) {
      return vec3(fract(cos(dot(uv, vec3(12.9898,  234.1418,    152.01))) * 43758.5453),
                  fract(sin(dot(uv, vec3(80.9898,  545.8937, 151515.12))) * 23411.1789),
                  fract(cos(dot(uv, vec3(01.9898, 1568.5439,    154.78))) * 31256.8817));
    }`;

    /* -------------------------------------------- */

    /**
     * A conventional noise generator
     * @type {string}
     */
    static NOISE = `
    float noise(in vec2 uv) {
      const vec2 d = vec2(0.0, 1.0);
      vec2 b = floor(uv);
      vec2 f = smoothstep(vec2(0.), vec2(1.0), fract(uv));
      return mix(
        mix(random(b), random(b + d.yx), f.x), 
        mix(random(b + d.xy), random(b + d.yy), f.x), 
        f.y
      );
    }`;

    /* -------------------------------------------- */

    /**
     * Convert a Hue-Saturation-Brightness color to RGB - useful to convert polar coordinates to RGB
     * @type {string}
     */
    static HSB2RGB = `
    vec3 hsb2rgb(in vec3 c) {
      vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0 );
      rgb = rgb*rgb*(3.0-2.0*rgb);
      return c.z * mix(vec3(1.0), rgb, c.y);
    }`;

    /* -------------------------------------------- */

    /**
     * Declare a wave function in a shader -> wcos (default), wsin or wtan.
     * Wave on the [v1,v2] range with amplitude -> a and speed -> speed.
     * @param {string} [func="cos"]     the math function to use
     * @returns {string}
     */
    static WAVE(func="cos") {
      return `
      float w${func}(in float v1, in float v2, in float a, in float speed) {
        float w = ${func}( speed + a ) + 1.0;
        return (v1 - v2) * (w * 0.5) + v2;
      }`;
    }

    /* -------------------------------------------- */

    /**
     * Rotation function.
     * @type {string}
     */
    static ROTATION = `
    mat2 rot(in float a) {
      float s = sin(a);
      float c = cos(a);
      return mat2(c, -s, s, c);
    }
    `;

    /* -------------------------------------------- */

    /**
     * Voronoi noise function. Needs PRNG2D and CONSTANTS.
     * @see PRNG2D
     * @see CONSTANTS
     * @type {string}
     */
    static VORONOI = `
    vec3 voronoi(in vec2 uv, in float t, in float zd) {
      vec3 vor = vec3(0.0, 0.0, zd);
      vec2 uvi = floor(uv);
      vec2 uvf = fract(uv);
      for ( float j = -1.0; j <= 1.0; j++ ) {
        for ( float i = -1.0; i <= 1.0; i++ ) {
          vec2 uvn = vec2(i, j);
          vec2 uvr = 0.5 * sin(TWOPI * random(uvi + uvn) + t) + 0.5;
          uvr = 0.5 * sin(TWOPI * uvr + t) + 0.5;
          vec2 uvd = uvn + uvr - uvf;
          float dist = length(uvd);
          if ( dist < vor.z ) {
            vor.xy = uvr;
            vor.z = dist;
          }
        }
      }
      return vor;
    }
    
    vec3 voronoi(in vec2 vuv, in float zd)  { 
      return voronoi(vuv, 0.0, zd); 
    }

    vec3 voronoi(in vec3 vuv, in float zd)  { 
      return voronoi(vuv.xy, vuv.z, zd);
    }
    `;

    /* -------------------------------------------- */

    /**
     * Enables GLSL 1.0 backwards compatibility in GLSL 3.00 ES vertex shaders.
     * @type {string}
     */
    static GLSL1_COMPATIBILITY_VERTEX = `
      #define attribute in
      #define varying out
    `;

    /* -------------------------------------------- */

    /**
     * Enables GLSL 1.0 backwards compatibility in GLSL 3.00 ES fragment shaders.
     * @type {string}
     */
    static GLSL1_COMPATIBILITY_FRAGMENT = `
      #define varying in
      #define texture2D texture
      #define textureCube texture
      #define texture2DProj textureProj
      #define texture2DLodEXT textureLod
      #define texture2DProjLodEXT textureProjLod
      #define textureCubeLodEXT textureLod
      #define texture2DGradEXT textureGrad
      #define texture2DProjGradEXT textureProjGrad
      #define textureCubeGradEXT textureGrad
      #define gl_FragDepthEXT gl_FragDepth
    `;
  }
  return BaseShaderMixin;
};

/**
 * This class defines an interface which all shaders utilize.
 * @extends {PIXI.Shader}
 * @property {PIXI.Program} program The program to use with this shader.
 * @property {object} uniforms      The current uniforms of the Shader.
 * @mixes BaseShaderMixin
 * @abstract
 */
class AbstractBaseShader extends BaseShaderMixin(PIXI.Shader) {
  constructor(program, uniforms) {
    super(program, foundry.utils.deepClone(uniforms));

    /**
     * The initial values of the shader uniforms.
     * @type {object}
     */
    this.initialUniforms = uniforms;
  }

  /* -------------------------------------------- */

  /**
   * The raw vertex shader used by this class.
   * A subclass of AbstractBaseShader must implement the vertexShader static field.
   * @type {string}
   */
  static vertexShader = "";

  /**
   * The raw fragment shader used by this class.
   * A subclass of AbstractBaseShader must implement the fragmentShader static field.
   * @type {string}
   */
  static fragmentShader = "";

  /**
   * The default uniform values for the shader.
   * A subclass of AbstractBaseShader must implement the defaultUniforms static field.
   * @type {object}
   */
  static defaultUniforms = {};

  /* -------------------------------------------- */

  /**
   * A factory method for creating the shader using its defined default values
   * @param {object} initialUniforms
   * @returns {AbstractBaseShader}
   */
  static create(initialUniforms) {
    const program = PIXI.Program.from(this.vertexShader, this.fragmentShader);
    const uniforms = foundry.utils.mergeObject(this.defaultUniforms, initialUniforms,
      {inplace: false, insertKeys: false});
    const shader = new this(program, uniforms);
    shader._configure();
    return shader;
  }

  /* -------------------------------------------- */

  /**
   * Reset the shader uniforms back to their initial values.
   */
  reset() {
    for (let [k, v] of Object.entries(this.initialUniforms)) {
      this.uniforms[k] = foundry.utils.deepClone(v);
    }
  }

  /* ---------------------------------------- */

  /**
   * A one time initialization performed on creation.
   * @protected
   */
  _configure() {}

  /* ---------------------------------------- */

  /**
   * Perform operations which are required before binding the Shader to the Renderer.
   * @param {PIXI.DisplayObject} mesh      The mesh display object linked to this shader.
   * @param {PIXI.Renderer} renderer       The renderer
   * @protected
   * @internal
   */
  _preRender(mesh, renderer) {}

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get _defaults() {
    const msg = "AbstractBaseShader#_defaults is deprecated in favor of AbstractBaseShader#initialUniforms.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.initialUniforms;
  }
}

/**
 * A mixin wich decorates a shader or filter and construct a fragment shader according to a choosen channel.
 * @category - Mixins
 * @param {typeof PIXI.Shader|PIXI.Filter} ShaderClass The parent ShaderClass class being mixed.
 * @returns {typeof AdaptiveFragmentChannelMixin}      A Shader/Filter subclass mixed with AdaptiveFragmentChannelMixin.
 * @mixin
 */
const AdaptiveFragmentChannelMixin = ShaderClass => {
  class AdaptiveFragmentChannelMixin extends ShaderClass {

    /**
     * The fragment shader which renders this filter.
     * A subclass of AdaptiveFragmentChannelMixin must implement the fragmentShader static field.
     * @type {Function}
     */
    static adaptiveFragmentShader = null;

    /**
     * A factory method for creating the filter using its defined default values
     * @param {object} [options]                Options which affect filter construction
     * @param {object} [options.uniforms]       Initial uniforms provided to the filter/shader
     * @param {string} [options.channel="r"]    The color channel to target for masking
     * @returns {PIXI.Shader|PIXI.Filter}
     */
    static create({channel="r", ...uniforms}={}) {
      this.fragmentShader = this.adaptiveFragmentShader(channel);
      return super.create(uniforms);
    }
  }
  return AdaptiveFragmentChannelMixin;
};

/**
 * An abstract filter which provides a framework for reusable definition
 * @extends {PIXI.Filter}
 * @mixes BaseShaderMixin
 * @abstract
 */
class AbstractBaseFilter extends BaseShaderMixin(PIXI.Filter) {
  /**
   * The default uniforms used by the filter
   * @type {object}
   */
  static defaultUniforms = {};

  /**
   * The fragment shader which renders this filter.
   * @type {string}
   */
  static fragmentShader = undefined;

  /**
   * The vertex shader which renders this filter.
   * @type {string}
   */
  static vertexShader = undefined;

  /**
   * A factory method for creating the filter using its defined default values.
   * @param {object} [initialUniforms]  Initial uniform values which override filter defaults
   * @returns {AbstractBaseFilter}      The constructed AbstractFilter instance.
   */
  static create(initialUniforms={}) {
    return new this(this.vertexShader, this.fragmentShader, {...this.defaultUniforms, ...initialUniforms});
  }
}


/**
 * The base sampler shader exposes a simple sprite shader and all the framework to handle:
 * - Batched shaders and plugin subscription
 * - Configure method (for special processing done once or punctually)
 * - Update method (pre-binding, normally done each frame)
 * All other sampler shaders (batched or not) should extend BaseSamplerShader
 */
class BaseSamplerShader extends AbstractBaseShader {

  /**
   * The named batch sampler plugin that is used by this shader, or null if no batching is used.
   * @type {string|null}
   */
  static classPluginName = "batch";

  /**
   * Is this shader pausable or not?
   * @type {boolean}
   */
  static pausable = true;

  /**
   * The plugin name associated for this instance, if any.
   * Returns "batch" if the shader is disabled.
   * @type {string|null}
   */
  get pluginName() {
    return this.#pluginName;
  }

  #pluginName = this.constructor.classPluginName;

  /**
   * Activate or deactivate this sampler. If set to false, the batch rendering is redirected to "batch".
   * Otherwise, the batch rendering is directed toward the instance pluginName (might be null)
   * @type {boolean}
   */
  get enabled() {
    return this.#enabled;
  }

  set enabled(enabled) {
    this.#pluginName = enabled ? this.constructor.classPluginName : "batch";
    this.#enabled = enabled;
  }

  #enabled = true;

  /**
   * Pause or Unpause this sampler. If set to true, the shader is disabled. Otherwise, it is enabled.
   * Contrary to enabled, a shader might decide to refuse a pause, to continue to render animations per example.
   * @see {enabled}
   * @type {boolean}
   */
  get paused() {
    return !this.#enabled;
  }

  set paused(paused) {
    if ( !this.constructor.pausable ) return;
    this.enabled = !paused;
  }

  /**
   * Contrast adjustment
   * @type {string}
   */
  static CONTRAST = `
    // Computing contrasted color
    if ( contrast != 0.0 ) {
      changedColor = (changedColor - 0.5) * (contrast + 1.0) + 0.5;
    }`;

  /**
   * Saturation adjustment
   * @type {string}
   */
  static SATURATION = `
    // Computing saturated color
    if ( saturation != 0.0 ) {
      vec3 grey = vec3(perceivedBrightness(changedColor));
      changedColor = mix(grey, changedColor, 1.0 + saturation);
    }`;

  /**
   * Exposure adjustment.
   * @type {string}
   */
  static EXPOSURE = `
    if ( exposure != 0.0 ) {
      changedColor *= (1.0 + exposure);
    }`;

  /**
   * The adjustments made into fragment shaders.
   * @type {string}
   */
  static get ADJUSTMENTS() {
    return `vec3 changedColor = baseColor.rgb;
      ${this.CONTRAST}
      ${this.SATURATION}
      ${this.EXPOSURE}
      baseColor.rgb = changedColor;`;
  }

  /** @override */
  static vertexShader = `
    precision ${PIXI.settings.PRECISION_VERTEX} float;
    attribute vec2 aVertexPosition;
    attribute vec2 aTextureCoord;
    uniform mat3 projectionMatrix;
    varying vec2 vUvs;

    void main() {
      gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
      vUvs = aTextureCoord;
    }
  `;

  /** @override */
  static fragmentShader = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;
    uniform sampler2D sampler;
    uniform vec4 tintAlpha;
    varying vec2 vUvs;

    void main() {
      gl_FragColor = texture2D(sampler, vUvs) * tintAlpha;
    }
  `;

  /**
   * The batch vertex shader source.
   * @type {string}
   */
  static batchVertexShader = `
    #version 300 es
    precision ${PIXI.settings.PRECISION_VERTEX} float;
    in vec2 aVertexPosition;
    in vec2 aTextureCoord;
    in vec4 aColor;
    in float aTextureId;
    uniform mat3 projectionMatrix;
    uniform mat3 translationMatrix;
    uniform vec4 tint;
    out vec2 vTextureCoord;
    flat out vec4 vColor;
    flat out float vTextureId;

    void main(void){
      gl_Position = vec4((projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
      vTextureCoord = aTextureCoord;
      vTextureId = aTextureId;
      vColor = aColor * tint;
    }
  `;

  /**
   * The batch fragment shader source.
   * @type {string}
   */
  static batchFragmentShader = `
    #version 300 es
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;
    in vec2 vTextureCoord;
    flat in vec4 vColor;
    flat in float vTextureId;
    uniform sampler2D uSamplers[%count%];
    out vec4 fragColor;

    #define texture2D texture

    void main(void){
      vec4 color;
      %forloop%
      fragColor = color * vColor;
    }
  `;

  /** @inheritdoc */
  static defaultUniforms = {
    sampler: 0,
    tintAlpha: [1, 1, 1, 1]
  };

  /**
   * Batch geometry associated with this sampler.
   * @type {typeof PIXI.BatchGeometry|{id: string, size: number, normalized: boolean, type: PIXI.TYPES}[]}
   */
  static batchGeometry = PIXI.BatchGeometry;

  /**
   * The size of a vertice with all its packed attributes.
   * @type {number}
   */
  static batchVertexSize = 6;

  /**
   * Pack interleaved geometry custom function.
   * @type {Function|undefined}
   * @protected
   */
  static _packInterleavedGeometry;

  /**
   * A prerender function happening just before the batch renderer is flushed.
   * @type {(batchRenderer: BatchRenderer) => void | undefined}
   * @protected
   */
  static _preRenderBatch;

  /**
   * A function that returns default uniforms associated with the batched version of this sampler.
   * @type {object}
   */
  static batchDefaultUniforms = {};

  /**
   * The number of reserved texture units for this shader that cannot be used by the batch renderer.
   * @type {number}
   */
  static reservedTextureUnits = 0;

  /**
   * Initialize the batch geometry with custom properties.
   */
  static initializeBatchGeometry() {}

  /**
   * The batch renderer to use.
   * @type {typeof BatchRenderer}
   */
  static batchRendererClass = BatchRenderer;

  /**
   * The batch generator to use.
   * @type {typeof BatchShaderGenerator}
   */
  static batchShaderGeneratorClass = BatchShaderGenerator;

  /* ---------------------------------------- */

  /**
   * Create a batch plugin for this sampler class.
   * @returns {typeof BatchPlugin}            The batch plugin class linked to this sampler class.
   */
  static createPlugin() {
    const shaderClass = this;
    const geometryClass = Array.isArray(shaderClass.batchGeometry)
      ? class BatchGeometry extends PIXI.Geometry {
        constructor(_static=false) {
          super();
          this._buffer = new PIXI.Buffer(null, _static, false);
          this._indexBuffer = new PIXI.Buffer(null, _static, true);
          for ( const {id, size, normalized, type} of shaderClass.batchGeometry ) {
            this.addAttribute(id, this._buffer, size, normalized, type);
          }
          this.addIndex(this._indexBuffer);
        }
      } : shaderClass.batchGeometry;
    return class BatchPlugin extends shaderClass.batchRendererClass {

      /** @override */
      static get shaderGeneratorClass() {
        return shaderClass.batchShaderGeneratorClass;
      }

      /* ---------------------------------------- */

      /** @override */
      static get defaultVertexSrc() {
        return shaderClass.batchVertexShader;
      }

      /* ---------------------------------------- */

      /** @override */
      static get defaultFragmentTemplate() {
        return shaderClass.batchFragmentShader;
      }

      /* ---------------------------------------- */

      /** @override */
      static get defaultUniforms() {
        return shaderClass.batchDefaultUniforms;
      }

      /* ---------------------------------------- */

      /**
       * The batch plugin constructor.
       * @param {PIXI.Renderer} renderer    The renderer
       */
      constructor(renderer) {
        super(renderer);
        this.geometryClass = geometryClass;
        this.vertexSize = shaderClass.batchVertexSize;
        this.reservedTextureUnits = shaderClass.reservedTextureUnits;
        this._packInterleavedGeometry = shaderClass._packInterleavedGeometry;
        this._preRenderBatch = shaderClass._preRenderBatch;
      }

      /* ---------------------------------------- */

      /** @inheritdoc */
      setShaderGenerator(options) {
        if ( !canvas.performance ) return;
        super.setShaderGenerator(options);
      }

      /* ---------------------------------------- */

      /** @inheritdoc */
      contextChange() {
        this.shaderGenerator = null;
        super.contextChange();
      }
    };
  }

  /* ---------------------------------------- */

  /**
   * Register the plugin for this sampler.
   * @param {object} [options]                The options
   * @param {object} [options.force=false]    Override the plugin of the same name that is already registered?
   */
  static registerPlugin({force=false}={}) {
    const pluginName = this.classPluginName;

    // Checking the pluginName
    if ( !(pluginName && (typeof pluginName === "string") && (pluginName.length > 0)) ) {
      const msg = `Impossible to create a PIXI plugin for ${this.name}. `
        + `The plugin name is invalid: [pluginName=${pluginName}]. `
        + "The plugin name must be a string with at least 1 character.";
      throw new Error(msg);
    }

    // Checking for existing plugins
    if ( !force && BatchRenderer.hasPlugin(pluginName) ) {
      const msg = `Impossible to create a PIXI plugin for ${this.name}. `
        + `The plugin name is already associated to a plugin in PIXI.Renderer: [pluginName=${pluginName}].`;
      throw new Error(msg);
    }

    // Initialize custom properties for the batch geometry
    this.initializeBatchGeometry();

    // Create our custom batch renderer for this geometry
    const plugin = this.createPlugin();

    // Register this plugin with its batch renderer
    PIXI.extensions.add({
      name: pluginName,
      type: PIXI.ExtensionType.RendererPlugin,
      ref: plugin
    });
  }

  /* ---------------------------------------- */

  /** @override */
  _preRender(mesh, renderer) {
    const uniforms = this.uniforms;
    uniforms.sampler = mesh.texture;
    uniforms.tintAlpha = mesh._cachedTint;
  }
}

/* eslint-disable no-tabs */

/**
 * @typedef {Object} ShaderTechnique
 * @property {number} id                      The numeric identifier of the technique
 * @property {string} label                   The localization string that labels the technique
 * @property {string|undefined} coloration    The coloration shader fragment when the technique is used
 * @property {string|undefined} illumination  The illumination shader fragment when the technique is used
 * @property {string|undefined} background    The background shader fragment when the technique is used
 */

/**
 * This class defines an interface which all adaptive lighting shaders extend.
 */
class AdaptiveLightingShader extends AbstractBaseShader {

  /**
   * Has this lighting shader a forced default color?
   * @type {boolean}
   */
  static forceDefaultColor = false;

  /* -------------------------------------------- */

  /** Called before rendering. */
  update() {
    this.uniforms.depthElevation = canvas.masks.depth.mapElevation(this.uniforms.elevation ?? 0);
  }

  /* -------------------------------------------- */

  /**
   * Common attributes for vertex shaders.
   * @type {string}
   */
  static VERTEX_ATTRIBUTES = `
  attribute vec2 aVertexPosition;
  attribute float aDepthValue;
  `;

  /**
   * Common uniforms for vertex shaders.
   * @type {string}
   */
  static VERTEX_UNIFORMS = `
  uniform mat3 translationMatrix;
  uniform mat3 projectionMatrix;
  uniform float rotation;
  uniform float angle;
  uniform float radius;
  uniform float depthElevation;
  uniform vec2 screenDimensions;
  uniform vec2 resolution;
  uniform vec3 origin;
  uniform vec3 dimensions;
  `;

  /**
   * Common varyings shared by vertex and fragment shaders.
   * @type {string}
   */
  static VERTEX_FRAGMENT_VARYINGS = `
  varying vec2 vUvs;
  varying vec2 vSamplerUvs;
  varying float vDepth;
  `;

  /**
   * Common functions used by the vertex shaders.
   * @type {string}
   * @abstract
   */
  static VERTEX_FUNCTIONS = "";

  /**
   * Common uniforms shared by fragment shaders.
   * @type {string}
   */
  static FRAGMENT_UNIFORMS = `
  uniform int technique;
  uniform bool useSampler;
  uniform bool hasColor;
  uniform bool computeIllumination;
  uniform bool linkedToDarknessLevel;
  uniform bool enableVisionMasking;
  uniform bool globalLight;
  uniform float attenuation;
  uniform float borderDistance;
  uniform float contrast;
  uniform float shadows;
  uniform float exposure;
  uniform float saturation;
  uniform float intensity;
  uniform float brightness;
  uniform float luminosity;
  uniform float pulse;
  uniform float brightnessPulse;
  uniform float backgroundAlpha;
  uniform float illuminationAlpha;
  uniform float colorationAlpha;
  uniform float ratio;
  uniform float time;
  uniform float darknessLevel;
  uniform float darknessPenalty;
  uniform vec2 globalLightThresholds;
  uniform vec3 color;
  uniform vec3 colorBackground;
  uniform vec3 colorVision;
  uniform vec3 colorTint;
  uniform vec3 colorEffect;
  uniform vec3 colorDim;
  uniform vec3 colorBright;
  uniform vec3 ambientDaylight;
  uniform vec3 ambientDarkness;
  uniform vec3 ambientBrightest;
  uniform int dimLevelCorrection;
  uniform int brightLevelCorrection;
  uniform vec4 weights;
  uniform sampler2D primaryTexture;
  uniform sampler2D framebufferTexture;
  uniform sampler2D depthTexture;
  uniform sampler2D darknessLevelTexture;
  uniform sampler2D visionTexture;

  // Shared uniforms with vertex shader
  uniform ${PIXI.settings.PRECISION_VERTEX} float rotation;
  uniform ${PIXI.settings.PRECISION_VERTEX} float angle;
  uniform ${PIXI.settings.PRECISION_VERTEX} float radius;
  uniform ${PIXI.settings.PRECISION_VERTEX} float depthElevation;
  uniform ${PIXI.settings.PRECISION_VERTEX} vec2 resolution;
  uniform ${PIXI.settings.PRECISION_VERTEX} vec2 screenDimensions;
  uniform ${PIXI.settings.PRECISION_VERTEX} vec3 origin;
  uniform ${PIXI.settings.PRECISION_VERTEX} vec3 dimensions;
  uniform ${PIXI.settings.PRECISION_VERTEX} mat3 translationMatrix;
  uniform ${PIXI.settings.PRECISION_VERTEX} mat3 projectionMatrix;
  `;

  /**
   * Common functions used by the fragment shaders.
   * @type {string}
   * @abstract
   */
  static FRAGMENT_FUNCTIONS = `
  #define DARKNESS -2
  #define HALFDARK -1
  #define UNLIT 0
  #define DIM 1
  #define BRIGHT 2
  #define BRIGHTEST 3
  
  vec3 computedDimColor;
  vec3 computedBrightColor;
  vec3 computedBackgroundColor;
  float computedDarknessLevel;
  
  vec3 getCorrectedColor(int level) {
    if ( (level == HALFDARK) || (level == DIM) ) {
      return computedDimColor;
    } else if ( (level == BRIGHT) || (level == DARKNESS) ) {
      return computedBrightColor;
    } else if ( level == BRIGHTEST ) {
      return ambientBrightest;
    } else if ( level == UNLIT ) {
      return computedBackgroundColor;
    } 
    return computedDimColor;
  }
  `;

  /** @inheritdoc */
  static CONSTANTS = `
    ${super.CONSTANTS}
    const float INVTHREE = 1.0 / 3.0;
    const vec2 PIVOT = vec2(0.5);
    const vec4 ALLONES = vec4(1.0);
  `;

  /** @inheritdoc */
  static vertexShader = `
  ${this.VERTEX_ATTRIBUTES}
  ${this.VERTEX_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.VERTEX_FUNCTIONS}

  void main() {
    vec3 tPos = translationMatrix * vec3(aVertexPosition, 1.0);
    vUvs = aVertexPosition * 0.5 + 0.5;
    vDepth = aDepthValue;
    vSamplerUvs = tPos.xy / screenDimensions;
    gl_Position = vec4((projectionMatrix * tPos).xy, 0.0, 1.0);
  }`;

  /* -------------------------------------------- */
  /*  GLSL Helper Functions                       */
  /* -------------------------------------------- */

  /**
   * Construct adaptive shader according to shader type
   * @param {string} shaderType  shader type to construct : coloration, illumination, background, etc.
   * @returns {string}           the constructed shader adaptive block
   */
  static getShaderTechniques(shaderType) {
    let shader = "";
    let index = 0;
    for ( let technique of Object.values(this.SHADER_TECHNIQUES) ) {
      if ( technique[shaderType] ) {
        let cond = `if ( technique == ${technique.id} )`;
        if ( index > 0 ) cond = `else ${cond}`;
        shader += `${cond} {${technique[shaderType]}\n}\n`;
        index++;
      }
    }
    return shader;
  }

  /* -------------------------------------------- */

  /**
   * The coloration technique coloration shader fragment
   * @type {string}
   */
  static get COLORATION_TECHNIQUES() {
    return this.getShaderTechniques("coloration");
  }

  /* -------------------------------------------- */

  /**
   * The coloration technique illumination shader fragment
   * @type {string}
   */
  static get ILLUMINATION_TECHNIQUES() {
    return this.getShaderTechniques("illumination");
  }

  /* -------------------------------------------- */

  /**
   * The coloration technique background shader fragment
   * @type {string}
   */
  static get BACKGROUND_TECHNIQUES() {
    return this.getShaderTechniques("background");
  }

  /* -------------------------------------------- */

  /**
   * The adjustments made into fragment shaders
   * @type {string}
   */
  static get ADJUSTMENTS() {
    return `vec3 changedColor = finalColor;\n
    ${this.CONTRAST}
    ${this.SATURATION}
    ${this.EXPOSURE}
    ${this.SHADOW}
    if ( useSampler ) finalColor = changedColor;`;
  }

  /* -------------------------------------------- */

  /**
   * Contrast adjustment
   * @type {string}
   */
  static CONTRAST = `
    // Computing contrasted color
    if ( contrast != 0.0 ) {
      changedColor = (changedColor - 0.5) * (contrast + 1.0) + 0.5;
    }`;

  /* -------------------------------------------- */

  /**
   * Saturation adjustment
   * @type {string}
   */
  static SATURATION = `
    // Computing saturated color
    if ( saturation != 0.0 ) {
      vec3 grey = vec3(perceivedBrightness(changedColor));
      changedColor = mix(grey, changedColor, 1.0 + saturation);
    }`;

  /* -------------------------------------------- */

  /**
   * Exposure adjustment
   * @type {string}
   */
  static EXPOSURE = `
    // Computing exposed color for background
    if ( exposure > 0.0 ) {
      float halfExposure = exposure * 0.5;
      float attenuationStrength = attenuation * 0.25;
      float lowerEdge = 0.98 - attenuationStrength;
      float upperEdge = 1.02 + attenuationStrength;
      float finalExposure = halfExposure *
                            (1.0 - smoothstep(ratio * lowerEdge, clamp(ratio * upperEdge, 0.0001, 1.0), dist)) +
                            halfExposure;
      changedColor *= (1.0 + finalExposure);
    }
    `;

  /* -------------------------------------------- */

  /**
   * Switch between an inner and outer color, by comparing distance from center to ratio
   * Apply a strong gradient between the two areas if attenuation uniform is set to true
   * @type {string}
   */
  static SWITCH_COLOR = `
    vec3 switchColor( in vec3 innerColor, in vec3 outerColor, in float dist ) {
      float attenuationStrength = attenuation * 0.7;
      float lowerEdge = 0.99 - attenuationStrength;
      float upperEdge = 1.01 + attenuationStrength;
      return mix(innerColor, outerColor, smoothstep(ratio * lowerEdge, clamp(ratio * upperEdge, 0.0001, 1.0), dist));
    }`;

  /* -------------------------------------------- */

  /**
   * Shadow adjustment
   * @type {string}
   */
  static SHADOW = `
    // Computing shadows
    if ( shadows != 0.0 ) {
      float shadowing = mix(1.0, smoothstep(0.50, 0.80, perceivedBrightness(changedColor)), shadows);
      // Applying shadow factor
      changedColor *= shadowing;
    }`;

  /* -------------------------------------------- */

  /**
   * Transition between bright and dim colors, if requested
   * @type {string}
   */
  static TRANSITION = `
  finalColor = switchColor(computedBrightColor, computedDimColor, dist);`;

  /**
   * Incorporate falloff if a attenuation uniform is requested
   * @type {string}
   */
  static FALLOFF = `
  if ( attenuation != 0.0 ) depth *= smoothstep(1.0, 1.0 - attenuation, dist);
  `;

  /**
   * Compute illumination uniforms
   * @type {string}
   */
  static COMPUTE_ILLUMINATION = `
  float weightDark = weights.x;
  float weightHalfdark = weights.y;
  float weightDim = weights.z;
  float weightBright = weights.w;
  
  if ( computeIllumination ) {
    computedDarknessLevel = texture2D(darknessLevelTexture, vSamplerUvs).r;  
    computedBackgroundColor = mix(ambientDaylight, ambientDarkness, computedDarknessLevel);
    computedBrightColor = mix(computedBackgroundColor, ambientBrightest, weightBright);
    computedDimColor = mix(computedBackgroundColor, computedBrightColor, weightDim);
    
    // Apply lighting levels
    vec3 correctedComputedBrightColor = getCorrectedColor(brightLevelCorrection);
    vec3 correctedComputedDimColor = getCorrectedColor(dimLevelCorrection);
    computedBrightColor = correctedComputedBrightColor;
    computedDimColor = correctedComputedDimColor;
  }
  else {
    computedBackgroundColor = colorBackground;
    computedDimColor = colorDim;
    computedBrightColor = colorBright;
    computedDarknessLevel = darknessLevel;
  }

  computedDimColor = max(computedDimColor, computedBackgroundColor);
  computedBrightColor = max(computedBrightColor, computedBackgroundColor);

  if ( globalLight && ((computedDarknessLevel < globalLightThresholds[0]) || (computedDarknessLevel > globalLightThresholds[1])) ) discard;
  `;

  /**
   * Initialize fragment with common properties
   * @type {string}
   */
  static FRAGMENT_BEGIN = `
  ${this.COMPUTE_ILLUMINATION}
  float dist = distance(vUvs, vec2(0.5)) * 2.0;
  vec4 depthColor = texture2D(depthTexture, vSamplerUvs);
  float depth = smoothstep(0.0, 1.0, vDepth) * step(depthColor.g, depthElevation) * step(depthElevation, (254.5 / 255.0) - depthColor.r);
  vec4 baseColor = useSampler ? texture2D(primaryTexture, vSamplerUvs) : vec4(1.0);
  vec3 finalColor = baseColor.rgb;
  `;

  /**
   * Shader final
   * @type {string}
   */
  static FRAGMENT_END = `
  gl_FragColor = vec4(finalColor, 1.0) * depth;
  `;

  /* -------------------------------------------- */
  /*  Shader Techniques for lighting              */
  /* -------------------------------------------- */

  /**
   * A mapping of available shader techniques
   * @type {Record<string, ShaderTechnique>}
   */
  static SHADER_TECHNIQUES = {
    LEGACY: {
      id: 0,
      label: "LIGHT.LegacyColoration"
    },
    LUMINANCE: {
      id: 1,
      label: "LIGHT.AdaptiveLuminance",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      finalColor *= reflection;`
    },
    INTERNAL_HALO: {
      id: 2,
      label: "LIGHT.InternalHalo",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      finalColor = switchColor(finalColor, finalColor * reflection, dist);`
    },
    EXTERNAL_HALO: {
      id: 3,
      label: "LIGHT.ExternalHalo",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      finalColor = switchColor(finalColor * reflection, finalColor, dist);`
    },
    COLOR_BURN: {
      id: 4,
      label: "LIGHT.ColorBurn",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      finalColor = (finalColor * (1.0 - sqrt(reflection))) / clamp(baseColor.rgb * 2.0, 0.001, 0.25);`
    },
    INTERNAL_BURN: {
      id: 5,
      label: "LIGHT.InternalBurn",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      finalColor = switchColor((finalColor * (1.0 - sqrt(reflection))) / clamp(baseColor.rgb * 2.0, 0.001, 0.25), finalColor * reflection, dist);`
    },
    EXTERNAL_BURN: {
      id: 6,
      label: "LIGHT.ExternalBurn",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      finalColor = switchColor(finalColor * reflection, (finalColor * (1.0 - sqrt(reflection))) / clamp(baseColor.rgb * 2.0, 0.001, 0.25), dist);`
    },
    LOW_ABSORPTION: {
      id: 7,
      label: "LIGHT.LowAbsorption",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      reflection *= smoothstep(0.35, 0.75, reflection);
      finalColor *= reflection;`
    },
    HIGH_ABSORPTION: {
      id: 8,
      label: "LIGHT.HighAbsorption",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      reflection *= smoothstep(0.55, 0.85, reflection);
      finalColor *= reflection;`
    },
    INVERT_ABSORPTION: {
      id: 9,
      label: "LIGHT.InvertAbsorption",
      coloration: `
      float r = reversePerceivedBrightness(baseColor);
      finalColor *= (r * r * r * r * r);`
    },
    NATURAL_LIGHT: {
      id: 10,
      label: "LIGHT.NaturalLight",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      finalColor *= reflection;`,
      background: `
      float ambientColorIntensity = perceivedBrightness(computedBackgroundColor);
      vec3 mutedColor = mix(finalColor, 
                            finalColor * mix(color, computedBackgroundColor, ambientColorIntensity), 
                            backgroundAlpha);
      finalColor = mix( finalColor,
                        mutedColor,
                        computedDarknessLevel);`
    }
  };

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  getDarknessPenalty(darknessLevel, luminosity) {
    const msg = "AdaptiveLightingShader#getDarknessPenalty is deprecated without replacement. " +
      "The darkness penalty is no longer applied on light and vision sources.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return 0;
  }
}

/**
 * This class defines an interface which all adaptive vision shaders extend.
 */
class AdaptiveVisionShader extends AdaptiveLightingShader {

  /** @inheritDoc */
  static FRAGMENT_FUNCTIONS = `
  ${super.FRAGMENT_FUNCTIONS}
  vec3 computedVisionColor;
  `;

  /* -------------------------------------------- */

  /** @override */
  static EXPOSURE = `
    // Computing exposed color for background
    if ( exposure != 0.0 ) {
      changedColor *= (1.0 + exposure);
    }`;

  /* -------------------------------------------- */

  /** @inheritDoc */
  static COMPUTE_ILLUMINATION = `
  ${super.COMPUTE_ILLUMINATION}
  if ( computeIllumination ) computedVisionColor = mix(computedDimColor, computedBrightColor, brightness);
  else computedVisionColor = colorVision;
  `;

  /* -------------------------------------------- */

  // FIXME: need to redeclare fragment begin here to take into account COMPUTE_ILLUMINATION
  //        Do not work without this redeclaration.
  /** @override */
  static FRAGMENT_BEGIN = `
  ${this.COMPUTE_ILLUMINATION}
  float dist = distance(vUvs, vec2(0.5)) * 2.0;
  vec4 depthColor = texture2D(depthTexture, vSamplerUvs);
  float depth = smoothstep(0.0, 1.0, vDepth) * step(depthColor.g, depthElevation) * step(depthElevation, (254.5 / 255.0) - depthColor.r);
  vec4 baseColor = useSampler ? texture2D(primaryTexture, vSamplerUvs) : vec4(1.0);
  vec3 finalColor = baseColor.rgb;
  `;

  /* -------------------------------------------- */

  /** @override */
  static SHADOW = "";

  /* -------------------------------------------- */
  /*  Shader Techniques for vision                */
  /* -------------------------------------------- */

  /**
   * A mapping of available shader techniques
   * @type {Record<string, ShaderTechnique>}
   */
  static SHADER_TECHNIQUES = {
    LEGACY: {
      id: 0,
      label: "LIGHT.AdaptiveLuminance",
      coloration: `
      float reflection = perceivedBrightness(baseColor);
      finalColor *= reflection;`
    }
  };
}

/**
 * The default coloration shader used by standard rendering and animations.
 * A fragment shader which creates a solid light source.
 */
class AdaptiveBackgroundShader extends AdaptiveLightingShader {

  /**
   * Memory allocations for the Adaptive Background Shader
   * @type {string}
   */
  static SHADER_HEADER = `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
  ${this.SWITCH_COLOR}
  `;

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}
    ${this.ADJUSTMENTS}
    ${this.BACKGROUND_TECHNIQUES}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;

  /** @override */
  static defaultUniforms = {
    technique: 1,
    contrast: 0,
    shadows: 0,
    saturation: 0,
    intensity: 5,
    attenuation: 0.5,
    exposure: 0,
    ratio: 0.5,
    color: [1, 1, 1],
    colorBackground: [1, 1, 1],
    screenDimensions: [1, 1],
    time: 0,
    useSampler: true,
    primaryTexture: null,
    depthTexture: null,
    darknessLevelTexture: null,
    depthElevation: 1,
    ambientBrightest: [1, 1, 1],
    ambientDarkness: [0, 0, 0],
    ambientDaylight: [1, 1, 1],
    weights: [0, 0, 0, 0],
    dimLevelCorrection: 1,
    brightLevelCorrection: 2,
    computeIllumination: false,
    globalLight: false,
    globalLightThresholds: [0, 0]
  };

  static {
    const initial = foundry.data.LightData.cleanData();
    this.defaultUniforms.technique = initial.coloration;
    this.defaultUniforms.contrast = initial.contrast;
    this.defaultUniforms.shadows = initial.shadows;
    this.defaultUniforms.saturation = initial.saturation;
    this.defaultUniforms.intensity = initial.animation.intensity;
    this.defaultUniforms.attenuation = initial.attenuation;
  }

  /**
   * Flag whether the background shader is currently required.
   * Check vision modes requirements first, then
   * if key uniforms are at their default values, we don't need to render the background container.
   * @type {boolean}
   */
  get isRequired() {
    const vs = canvas.visibility.lightingVisibility;

    // Checking if a vision mode is forcing the rendering
    if ( vs.background === VisionMode.LIGHTING_VISIBILITY.REQUIRED ) return true;

    // Checking if disabled
    if ( vs.background === VisionMode.LIGHTING_VISIBILITY.DISABLED ) return false;

    // Then checking keys
    const keys = ["contrast", "saturation", "shadows", "exposure", "technique"];
    return keys.some(k => this.uniforms[k] !== this.constructor.defaultUniforms[k]);
  }
}

/**
 * The default coloration shader used by standard rendering and animations.
 * A fragment shader which creates a light source.
 */
class AdaptiveColorationShader extends AdaptiveLightingShader {

  /** @override */
  static FRAGMENT_END = `
  gl_FragColor = vec4(finalColor * depth, 1.0);
  `;

  /**
   * The adjustments made into fragment shaders
   * @type {string}
   */
  static get ADJUSTMENTS() {
    return `
      vec3 changedColor = finalColor;\n
      ${this.SATURATION}
      ${this.SHADOW}
      finalColor = changedColor;\n`;
  }

  /** @override */
  static SHADOW = `
    // Computing shadows
    if ( shadows != 0.0 ) {
      float shadowing = mix(1.0, smoothstep(0.25, 0.35, perceivedBrightness(baseColor.rgb)), shadows);
      // Applying shadow factor
      changedColor *= shadowing;
    }
  `;

  /**
   * Memory allocations for the Adaptive Coloration Shader
   * @type {string}
   */
  static SHADER_HEADER = `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
  ${this.SWITCH_COLOR}
  `;

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}
  
  void main() {
    ${this.FRAGMENT_BEGIN}
    finalColor = color * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;

  /** @inheritdoc */
  static defaultUniforms = {
    technique: 1,
    shadows: 0,
    contrast: 0,
    saturation: 0,
    colorationAlpha: 1,
    intensity: 5,
    attenuation: 0.5,
    ratio: 0.5,
    color: [1, 1, 1],
    time: 0,
    hasColor: false,
    screenDimensions: [1, 1],
    useSampler: false,
    primaryTexture: null,
    depthTexture: null,
    darknessLevelTexture: null,
    depthElevation: 1,
    ambientBrightest: [1, 1, 1],
    ambientDarkness: [0, 0, 0],
    ambientDaylight: [1, 1, 1],
    weights: [0, 0, 0, 0],
    dimLevelCorrection: 1,
    brightLevelCorrection: 2,
    computeIllumination: false,
    globalLight: false,
    globalLightThresholds: [0, 0]
  };

  static {
    const initial = foundry.data.LightData.cleanData();
    this.defaultUniforms.technique = initial.coloration;
    this.defaultUniforms.contrast = initial.contrast;
    this.defaultUniforms.shadows = initial.shadows;
    this.defaultUniforms.saturation = initial.saturation;
    this.defaultUniforms.intensity = initial.animation.intensity;
    this.defaultUniforms.attenuation = initial.attenuation;
  }

  /**
   * Flag whether the coloration shader is currently required.
   * @type {boolean}
   */
  get isRequired() {
    const vs = canvas.visibility.lightingVisibility;

    // Checking if a vision mode is forcing the rendering
    if ( vs.coloration === VisionMode.LIGHTING_VISIBILITY.REQUIRED ) return true;

    // Checking if disabled
    if ( vs.coloration === VisionMode.LIGHTING_VISIBILITY.DISABLED ) return false;

    // Otherwise, we need the coloration if it has color
    return this.constructor.forceDefaultColor || this.uniforms.hasColor;
  }
}

/**
 * The default coloration shader used by standard rendering and animations.
 * A fragment shader which creates a solid light source.
 */
class AdaptiveDarknessShader extends AdaptiveLightingShader {

  /** @override */
  update() {
    super.update();
    this.uniforms.darknessLevel = canvas.environment.darknessLevel;
  }

  /* -------------------------------------------- */

  /**
   * Flag whether the darkness shader is currently required.
   * Check vision modes requirements first, then
   * if key uniforms are at their default values, we don't need to render the background container.
   * @type {boolean}
   */
  get isRequired() {
    const vs = canvas.visibility.lightingVisibility;

    // Checking if darkness layer is disabled
    if ( vs.darkness === VisionMode.LIGHTING_VISIBILITY.DISABLED ) return false;

    // Otherwise, returns true in every circumstances
    return true;
  }

  /* -------------------------------------------- */
  /*  GLSL Statics                                */
  /* -------------------------------------------- */

  /** @override */
  static defaultUniforms = {
    intensity: 5,
    color: Color.from("#8651d5").rgb,
    screenDimensions: [1, 1],
    time: 0,
    primaryTexture: null,
    depthTexture: null,
    visionTexture: null,
    darknessLevelTexture: null,
    depthElevation: 1,
    ambientBrightest: [1, 1, 1],
    ambientDarkness: [0, 0, 0],
    ambientDaylight: [1, 1, 1],
    weights: [0, 0, 0, 0],
    dimLevelCorrection: 1,
    brightLevelCorrection: 2,
    borderDistance: 0,
    darknessLevel: 0,
    computeIllumination: false,
    globalLight: false,
    globalLightThresholds: [0, 0],
    enableVisionMasking: false
  };

  static {
    const initial = foundry.data.LightData.cleanData();
    this.defaultUniforms.intensity = initial.animation.intensity;
  }

  /* -------------------------------------------- */

  /**
   * Shader final
   * @type {string}
   */
  static FRAGMENT_END = `
  gl_FragColor = vec4(finalColor, 1.0) * depth;
  `;

  /* -------------------------------------------- */

  /**
   * Initialize fragment with common properties
   * @type {string}
   */
  static FRAGMENT_BEGIN = `
  ${this.COMPUTE_ILLUMINATION}
  float dist = distance(vUvs, vec2(0.5)) * 2.0;
  vec4 depthColor = texture2D(depthTexture, vSamplerUvs);
  float depth = smoothstep(0.0, 1.0, vDepth) * 
                step(depthColor.g, depthElevation) * 
                step(depthElevation, (254.5 / 255.0) - depthColor.r) *
                (enableVisionMasking ? 1.0 - step(texture2D(visionTexture, vSamplerUvs).r, 0.0) : 1.0) *
                (1.0 - smoothstep(borderDistance, 1.0, dist));
  vec4 baseColor = texture2D(primaryTexture, vSamplerUvs);
  vec3 finalColor = baseColor.rgb;
  `;

  /* -------------------------------------------- */

  /**
   * Memory allocations for the Adaptive Background Shader
   * @type {string}
   */
  static SHADER_HEADER = `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
  `;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}
    finalColor *= (mix(color, color * 0.33, darknessLevel) * colorationAlpha);
    ${this.FRAGMENT_END}
  }`;
}

/**
 * The default coloration shader used by standard rendering and animations.
 * A fragment shader which creates a solid light source.
 */
class AdaptiveIlluminationShader extends AdaptiveLightingShader {

  /** @inheritdoc */
  static FRAGMENT_BEGIN = `
  ${super.FRAGMENT_BEGIN}
  vec3 framebufferColor = min(texture2D(framebufferTexture, vSamplerUvs).rgb, computedBackgroundColor);
  `;

  /** @override */
  static FRAGMENT_END = `
  gl_FragColor = vec4(mix(framebufferColor, finalColor, depth), 1.0);
  `;

  /**
   * The adjustments made into fragment shaders
   * @type {string}
   */
  static get ADJUSTMENTS() {
    return `
      vec3 changedColor = finalColor;\n
      ${this.SATURATION}
      ${this.EXPOSURE}
      ${this.SHADOW}
      finalColor = changedColor;\n`;
  }

  static EXPOSURE = `
    // Computing exposure with illumination
    if ( exposure > 0.0 ) {
      // Diminishing exposure for illumination by a factor 2 (to reduce the "inflating radius" visual problem)
      float quartExposure = exposure * 0.25;
      float attenuationStrength = attenuation * 0.25;
      float lowerEdge = 0.98 - attenuationStrength;
      float upperEdge = 1.02 + attenuationStrength;
      float finalExposure = quartExposure *
                            (1.0 - smoothstep(ratio * lowerEdge, clamp(ratio * upperEdge, 0.0001, 1.0), dist)) +
                            quartExposure;
      changedColor *= (1.0 + finalExposure);
    }
    else if ( exposure != 0.0 ) changedColor *= (1.0 + exposure);
  `;

  /**
   * Memory allocations for the Adaptive Illumination Shader
   * @type {string}
   */
  static SHADER_HEADER = `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
  ${this.SWITCH_COLOR}
  `;

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}
    ${this.TRANSITION}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;

  /** @inheritdoc */
  static defaultUniforms = {
    technique: 1,
    shadows: 0,
    saturation: 0,
    intensity: 5,
    attenuation: 0.5,
    contrast: 0,
    exposure: 0,
    ratio: 0.5,
    darknessLevel: 0,
    color: [1, 1, 1],
    colorBackground: [1, 1, 1],
    colorDim: [1, 1, 1],
    colorBright: [1, 1, 1],
    screenDimensions: [1, 1],
    time: 0,
    useSampler: false,
    primaryTexture: null,
    framebufferTexture: null,
    depthTexture: null,
    darknessLevelTexture: null,
    depthElevation: 1,
    ambientBrightest: [1, 1, 1],
    ambientDarkness: [0, 0, 0],
    ambientDaylight: [1, 1, 1],
    weights: [0, 0, 0, 0],
    dimLevelCorrection: 1,
    brightLevelCorrection: 2,
    computeIllumination: false,
    globalLight: false,
    globalLightThresholds: [0, 0]
  };

  static {
    const initial = foundry.data.LightData.cleanData();
    this.defaultUniforms.technique = initial.coloration;
    this.defaultUniforms.contrast = initial.contrast;
    this.defaultUniforms.shadows = initial.shadows;
    this.defaultUniforms.saturation = initial.saturation;
    this.defaultUniforms.intensity = initial.animation.intensity;
    this.defaultUniforms.attenuation = initial.attenuation;
  }

  /**
   * Flag whether the illumination shader is currently required.
   * @type {boolean}
   */
  get isRequired() {
    const vs = canvas.visibility.lightingVisibility;

    // Checking if disabled
    if ( vs.illumination === VisionMode.LIGHTING_VISIBILITY.DISABLED ) return false;

    // For the moment, we return everytimes true if we are here
    return true;
  }
}

/**
 * The shader used by {@link RegionMesh}.
 */
class RegionShader extends AbstractBaseShader {

  /** @override */
  static vertexShader = `
    precision ${PIXI.settings.PRECISION_VERTEX} float;

    attribute vec2 aVertexPosition;

    uniform mat3 translationMatrix;
    uniform mat3 projectionMatrix;
    uniform vec2 canvasDimensions;
    uniform vec4 sceneDimensions;
    uniform vec2 screenDimensions;

    varying vec2 vCanvasCoord; // normalized canvas coordinates
    varying vec2 vSceneCoord; // normalized scene coordinates
    varying vec2 vScreenCoord; // normalized screen coordinates

    void main() {
      vec2 pixelCoord = aVertexPosition;
      vCanvasCoord = pixelCoord / canvasDimensions;
      vSceneCoord = (pixelCoord - sceneDimensions.xy) / sceneDimensions.zw;
      vec3 tPos = translationMatrix * vec3(aVertexPosition, 1.0);
      vScreenCoord = tPos.xy / screenDimensions;
      gl_Position = vec4((projectionMatrix * tPos).xy, 0.0, 1.0);
    }
  `;

  /** @override */
  static fragmentShader = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;

    uniform vec4 tintAlpha;

    void main() {
      gl_FragColor = tintAlpha;
    }
  `;

  /* ---------------------------------------- */

  /** @override */
  static defaultUniforms = {
    canvasDimensions: [1, 1],
    sceneDimensions: [0, 0, 1, 1],
    screenDimensions: [1, 1],
    tintAlpha: [1, 1, 1, 1]
  };

  /* ---------------------------------------- */

  /** @override */
  _preRender(mesh, renderer) {
    const uniforms = this.uniforms;
    uniforms.tintAlpha = mesh._cachedTint;
    const dimensions = canvas.dimensions;
    uniforms.canvasDimensions[0] = dimensions.width;
    uniforms.canvasDimensions[1] = dimensions.height;
    uniforms.sceneDimensions = dimensions.sceneRect;
    uniforms.screenDimensions = canvas.screenDimensions;
  }
}

/**
 * Abstract shader used for Adjust Darkness Level region behavior.
 * @abstract
 * @internal
 * @ignore
 */
class AbstractDarknessLevelRegionShader extends RegionShader {

  /** @inheritDoc */
  static defaultUniforms = {
    ...super.defaultUniforms,
    bottom: 0,
    top: 0,
    depthTexture: null
  };

  /* ---------------------------------------- */

  /**
   * The darkness level adjustment mode.
   * @type {number}
   */
  mode = foundry.data.regionBehaviors.AdjustDarknessLevelRegionBehaviorType.MODES.OVERRIDE;

  /* ---------------------------------------- */

  /**
   * The darkness level modifier.
   * @type {number}
   */
  modifier = 0;

  /* ---------------------------------------- */

  /**
   * Current darkness level of this mesh.
   * @type {number}
   */
  get darknessLevel() {
    const M = foundry.data.regionBehaviors.AdjustDarknessLevelRegionBehaviorType.MODES;
    switch ( this.mode ) {
      case M.OVERRIDE: return this.modifier;
      case M.BRIGHTEN: return canvas.environment.darknessLevel * (1 - this.modifier);
      case M.DARKEN: return 1 - ((1 - canvas.environment.darknessLevel) * (1 - this.modifier));
      default: throw new Error("Invalid mode");
    }
  }

  /* ---------------------------------------- */

  /** @inheritDoc */
  _preRender(mesh, renderer) {
    super._preRender(mesh, renderer);
    const u = this.uniforms;
    u.bottom = canvas.masks.depth.mapElevation(mesh.region.bottom);
    u.top = canvas.masks.depth.mapElevation(mesh.region.top);
    if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
  }
}

/* ---------------------------------------- */

/**
 * Render the RegionMesh with darkness level adjustments.
 * @internal
 * @ignore
 */
class AdjustDarknessLevelRegionShader extends AbstractDarknessLevelRegionShader {

  /** @override */
  static fragmentShader = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;

    uniform sampler2D depthTexture;
    uniform float darknessLevel;
    uniform float top;
    uniform float bottom;
    uniform vec4 tintAlpha;
    varying vec2 vScreenCoord;

    void main() {
      vec2 depthColor = texture2D(depthTexture, vScreenCoord).rg;
      float depth = step(depthColor.g, top) * step(bottom, (254.5 / 255.0) - depthColor.r);
      gl_FragColor = vec4(darknessLevel, 0.0, 0.0, 1.0) * tintAlpha * depth;
    }
  `;

  /* ---------------------------------------- */

  /** @inheritDoc */
  static defaultUniforms = {
    ...super.defaultUniforms,
    darknessLevel: 0
  };

  /* ---------------------------------------- */

  /** @inheritDoc */
  _preRender(mesh, renderer) {
    super._preRender(mesh, renderer);
    this.uniforms.darknessLevel = this.darknessLevel;
  }
}

/* ---------------------------------------- */

/**
 * Render the RegionMesh with darkness level adjustments.
 * @internal
 * @ignore
 */
class IlluminationDarknessLevelRegionShader extends AbstractDarknessLevelRegionShader {

  /** @override */
  static fragmentShader = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;

    uniform sampler2D depthTexture;
    uniform float top;
    uniform float bottom;
    uniform vec4 tintAlpha;
    varying vec2 vScreenCoord;

    void main() {
      vec2 depthColor = texture2D(depthTexture, vScreenCoord).rg;
      float depth = step(depthColor.g, top) * step(bottom, (254.5 / 255.0) - depthColor.r);
      gl_FragColor = vec4(1.0) * tintAlpha * depth;
    }
  `;
}

/**
 * Shader for the Region highlight.
 * @internal
 * @ignore
 */
class HighlightRegionShader extends RegionShader {

  /** @override */
  static vertexShader = `\
    precision ${PIXI.settings.PRECISION_VERTEX} float;

    ${this.CONSTANTS}

    attribute vec2 aVertexPosition;

    uniform mat3 translationMatrix;
    uniform mat3 projectionMatrix;
    uniform vec2 canvasDimensions;
    uniform vec4 sceneDimensions;
    uniform vec2 screenDimensions;
    uniform mediump float hatchThickness;

    varying vec2 vCanvasCoord; // normalized canvas coordinates
    varying vec2 vSceneCoord; // normalized scene coordinates
    varying vec2 vScreenCoord; // normalized screen coordinates
    varying float vHatchOffset;

    void main() {
      vec2 pixelCoord = aVertexPosition;
      vCanvasCoord = pixelCoord / canvasDimensions;
      vSceneCoord = (pixelCoord - sceneDimensions.xy) / sceneDimensions.zw;
      vec3 tPos = translationMatrix * vec3(aVertexPosition, 1.0);
      vScreenCoord = tPos.xy / screenDimensions;
      gl_Position = vec4((projectionMatrix * tPos).xy, 0.0, 1.0);
      vHatchOffset = (pixelCoord.x + pixelCoord.y) / (SQRT2 * 2.0 * hatchThickness);
    }
  `;

  /* ---------------------------------------- */

  /** @override */
  static fragmentShader = `\
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;

    varying float vHatchOffset;

    uniform vec4 tintAlpha;
    uniform float resolution;
    uniform bool hatchEnabled;
    uniform mediump float hatchThickness;

    void main() {
      gl_FragColor = tintAlpha;
      if ( !hatchEnabled ) return;
      float x = abs(vHatchOffset - floor(vHatchOffset + 0.5)) * 2.0;
      float s = hatchThickness * resolution;
      float y0 = clamp((x + 0.5) * s + 0.5, 0.0, 1.0);
      float y1 = clamp((x - 0.5) * s + 0.5, 0.0, 1.0);
      gl_FragColor *= mix(0.3333, 1.0, y0 - y1);
    }
  `;

  /* ---------------------------------------- */

  /** @inheritDoc */
  static defaultUniforms = {
    ...super.defaultUniforms,
    resolution: 1,
    hatchEnabled: false,
    hatchThickness: 1
  };

  /** @inheritDoc */
  _preRender(mesh, renderer) {
    super._preRender(mesh, renderer);
    const uniforms = this.uniforms;
    uniforms.resolution = (renderer.renderTexture.current ?? renderer).resolution * mesh.worldTransform.a;
    const projection = renderer.projection.transform;
    if ( projection ) {
      const {a, b} = projection;
      uniforms.resolution *= Math.sqrt((a * a) + (b * b));
    }
  }
}

/**
 * The default background shader used for vision sources
 */
class BackgroundVisionShader extends AdaptiveVisionShader {

  /** @inheritdoc */
  static FRAGMENT_END = `
  finalColor *= colorTint;
  if ( linkedToDarknessLevel ) finalColor = mix(baseColor.rgb, finalColor, computedDarknessLevel);
  ${super.FRAGMENT_END}
  `;

  /**
   * Adjust the intensity according to the difference between the pixel darkness level and the scene darkness level.
   * Used to see the difference of intensity when computing the background shader which is completeley overlapping
   * The surface texture.
   * @type {string}
   */
  static ADJUST_INTENSITY = `
  float darknessLevelDifference = clamp(computedDarknessLevel - darknessLevel, 0.0, 1.0);
  finalColor = mix(finalColor, finalColor * 0.5, darknessLevelDifference);
  `;

  /** @inheritdoc */
  static ADJUSTMENTS = `
  ${this.ADJUST_INTENSITY}
  ${super.ADJUSTMENTS}
  `;

  /**
   * Memory allocations for the Adaptive Background Shader
   * @type {string}
   */
  static SHADER_HEADER = `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}`;

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}
    ${this.ADJUSTMENTS}
    ${this.BACKGROUND_TECHNIQUES}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;

  /** @inheritdoc */
  static defaultUniforms = {
    technique: 0,
    saturation: 0,
    contrast: 0,
    attenuation: 0.10,
    exposure: 0,
    darknessLevel: 0,
    colorVision: [1, 1, 1],
    colorTint: [1, 1, 1],
    colorBackground: [1, 1, 1],
    screenDimensions: [1, 1],
    time: 0,
    useSampler: true,
    linkedToDarknessLevel: true,
    primaryTexture: null,
    depthTexture: null,
    darknessLevelTexture: null,
    depthElevation: 1,
    ambientBrightest: [1, 1, 1],
    ambientDarkness: [0, 0, 0],
    ambientDaylight: [1, 1, 1],
    weights: [0, 0, 0, 0],
    dimLevelCorrection: 1,
    brightLevelCorrection: 2,
    globalLight: false,
    globalLightThresholds: [0, 0]
  };

  /**
   * Flag whether the background shader is currently required.
   * If key uniforms are at their default values, we don't need to render the background container.
   * @type {boolean}
   */
  get isRequired() {
    const keys = ["contrast", "saturation", "colorTint", "colorVision"];
    return keys.some(k => this.uniforms[k] !== this.constructor.defaultUniforms[k]);
  }
}

/**
 * The default coloration shader used for vision sources.
 */
class ColorationVisionShader extends AdaptiveVisionShader {

  /** @override */
  static EXPOSURE = "";

  /** @override */
  static CONTRAST = "";

  /**
   * Memory allocations for the Adaptive Coloration Shader
   * @type {string}
   */
  static SHADER_HEADER = `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
  `;

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}
  
  void main() {
    ${this.FRAGMENT_BEGIN}
    finalColor = colorEffect;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;

  /** @inheritdoc */
  static defaultUniforms = {
    technique: 0,
    saturation: 0,
    attenuation: 0,
    colorEffect: [0, 0, 0],
    colorBackground: [0, 0, 0],
    colorTint: [1, 1, 1],
    time: 0,
    screenDimensions: [1, 1],
    useSampler: true,
    primaryTexture: null,
    linkedToDarknessLevel: true,
    depthTexture: null,
    depthElevation: 1,
    ambientBrightest: [1, 1, 1],
    ambientDarkness: [0, 0, 0],
    ambientDaylight: [1, 1, 1],
    weights: [0, 0, 0, 0],
    dimLevelCorrection: 1,
    brightLevelCorrection: 2,
    globalLight: false,
    globalLightThresholds: [0, 0]
  };

  /**
   * Flag whether the coloration shader is currently required.
   * If key uniforms are at their default values, we don't need to render the coloration container.
   * @type {boolean}
   */
  get isRequired() {
    const keys = ["saturation", "colorEffect"];
    return keys.some(k => this.uniforms[k] !== this.constructor.defaultUniforms[k]);
  }
}

/**
 * The default illumination shader used for vision sources
 */
class IlluminationVisionShader extends AdaptiveVisionShader {

  /** @inheritdoc */
  static FRAGMENT_BEGIN = `
  ${super.FRAGMENT_BEGIN}
  vec3 framebufferColor = min(texture2D(framebufferTexture, vSamplerUvs).rgb, computedBackgroundColor);
  `;

  /** @override */
  static FRAGMENT_END = `
  gl_FragColor = vec4(mix(framebufferColor, finalColor, depth), 1.0);
  `;

  /**
   * Transition between bright and dim colors, if requested
   * @type {string}
   */
  static VISION_COLOR = `
  finalColor = computedVisionColor;
  `;

  /**
   * The adjustments made into fragment shaders
   * @type {string}
   */
  static get ADJUSTMENTS() {
    return `
      vec3 changedColor = finalColor;\n
      ${this.SATURATION}
      finalColor = changedColor;\n`;
  }

  /**
   * Memory allocations for the Adaptive Illumination Shader
   * @type {string}
   */
  static SHADER_HEADER = `
  ${this.FRAGMENT_UNIFORMS}
  ${this.VERTEX_FRAGMENT_VARYINGS}
  ${this.FRAGMENT_FUNCTIONS}
  ${this.CONSTANTS}
  `;

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}
    ${this.VISION_COLOR}
    ${this.ILLUMINATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;

  /** @inheritdoc */
  static defaultUniforms = {
    technique: foundry.data.LightData.cleanData().initial,
    attenuation: 0,
    exposure: 0,
    saturation: 0,
    darknessLevel: 0,
    colorVision: [1, 1, 1],
    colorTint: [1, 1, 1],
    colorBackground: [1, 1, 1],
    screenDimensions: [1, 1],
    time: 0,
    useSampler: false,
    linkedToDarknessLevel: true,
    primaryTexture: null,
    framebufferTexture: null,
    depthTexture: null,
    darknessLevelTexture: null,
    depthElevation: 1,
    ambientBrightest: [1, 1, 1],
    ambientDarkness: [0, 0, 0],
    ambientDaylight: [1, 1, 1],
    weights: [0, 0, 0, 0],
    dimLevelCorrection: 1,
    brightLevelCorrection: 2,
    globalLight: false,
    globalLightThresholds: [0, 0]
  };
}

/**
 * The batch data that is needed by {@link DepthSamplerShader} to render an element with batching.
 * @typedef {object} DepthBatchData
 * @property {PIXI.Texture} _texture                       The texture
 * @property {Float32Array} vertexData                     The vertices
 * @property {Uint16Array|Uint32Array|number[]} indices    The indices
 * @property {Float32Array} uvs                            The texture UVs
 * @property {number} elevation                            The elevation
 * @property {number} textureAlphaThreshold                The texture alpha threshold
 * @property {number} fadeOcclusion                        The amount of FADE occlusion
 * @property {number} radialOcclusion                      The amount of RADIAL occlusion
 * @property {number} visionOcclusion                      The amount of VISION occlusion
 */

/**
 * The depth sampler shader.
 */
class DepthSamplerShader extends BaseSamplerShader {

  /* -------------------------------------------- */
  /*  Batched version Rendering                   */
  /* -------------------------------------------- */

  /** @override */
  static classPluginName = "batchDepth";

  /* ---------------------------------------- */

  /** @override */
  static batchGeometry = [
    {id: "aVertexPosition", size: 2, normalized: false, type: PIXI.TYPES.FLOAT},
    {id: "aTextureCoord", size: 2, normalized: false, type: PIXI.TYPES.FLOAT},
    {id: "aTextureId", size: 1, normalized: false, type: PIXI.TYPES.UNSIGNED_BYTE},
    {id: "aTextureAlphaThreshold", size: 1, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE},
    {id: "aDepthElevation", size: 1, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE},
    {id: "aRestrictionState", size: 1, normalized: false, type: PIXI.TYPES.UNSIGNED_BYTE},
    {id: "aOcclusionData", size: 4, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE}
  ];

  /* ---------------------------------------- */

  /** @override */
  static batchVertexSize = 6;

  /* -------------------------------------------- */

  /** @override */
  static reservedTextureUnits = 1; // We need a texture unit for the occlusion texture

  /* -------------------------------------------- */

  /** @override */
  static defaultUniforms = {
    screenDimensions: [1, 1],
    sampler: null,
    occlusionTexture: null,
    textureAlphaThreshold: 0,
    depthElevation: 0,
    occlusionElevation: 0,
    fadeOcclusion: 0,
    radialOcclusion: 0,
    visionOcclusion: 0,
    restrictsLight: false,
    restrictsWeather: false
  };

  /* -------------------------------------------- */

  /** @override */
  static batchDefaultUniforms(maxTex) {
    return {
      screenDimensions: [1, 1],
      occlusionTexture: maxTex
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static _preRenderBatch(batchRenderer) {
    const uniforms = batchRenderer._shader.uniforms;
    uniforms.screenDimensions = canvas.screenDimensions;
    batchRenderer.renderer.texture.bind(canvas.masks.occlusion.renderTexture, uniforms.occlusionTexture);
  }

  /* ---------------------------------------- */

  /** @override */
  static _packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex) {
    const {float32View, uint8View} = attributeBuffer;

    // Write indices into buffer
    const packedVertices = aIndex / this.vertexSize;
    const indices = element.indices;
    for ( let i = 0; i < indices.length; i++ ) {
      indexBuffer[iIndex++] = packedVertices + indices[i];
    }

    // Prepare attributes
    const vertexData = element.vertexData;
    const uvs = element.uvs;
    const textureId = element._texture.baseTexture._batchLocation;
    const restrictionState = element.restrictionState;
    const textureAlphaThreshold = (element.textureAlphaThreshold * 255) | 0;
    const depthElevation = (canvas.masks.depth.mapElevation(element.elevation) * 255) | 0;
    const occlusionElevation = (canvas.masks.occlusion.mapElevation(element.elevation) * 255) | 0;
    const fadeOcclusion = (element.fadeOcclusion * 255) | 0;
    const radialOcclusion = (element.radialOcclusion * 255) | 0;
    const visionOcclusion = (element.visionOcclusion * 255) | 0;

    // Write attributes into buffer
    const vertexSize = this.vertexSize;
    for ( let i = 0, j = 0; i < vertexData.length; i += 2, j += vertexSize ) {
      let k = aIndex + j;
      float32View[k++] = vertexData[i];
      float32View[k++] = vertexData[i + 1];
      float32View[k++] = uvs[i];
      float32View[k++] = uvs[i + 1];
      k <<= 2;
      uint8View[k++] = textureId;
      uint8View[k++] = textureAlphaThreshold;
      uint8View[k++] = depthElevation;
      uint8View[k++] = restrictionState;
      uint8View[k++] = occlusionElevation;
      uint8View[k++] = fadeOcclusion;
      uint8View[k++] = radialOcclusion;
      uint8View[k++] = visionOcclusion;
    }
  }

  /* ---------------------------------------- */

  /** @override */
  static get batchVertexShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_VERTEX}

      precision ${PIXI.settings.PRECISION_VERTEX} float;

      in vec2 aVertexPosition;
      in vec2 aTextureCoord;

      uniform vec2 screenDimensions;

      ${this._batchVertexShader}

      in float aTextureId;
      in float aTextureAlphaThreshold;
      in float aDepthElevation;
      in vec4 aOcclusionData;
      in float aRestrictionState;

      uniform mat3 projectionMatrix;
      uniform mat3 translationMatrix;

      out vec2 vTextureCoord;
      out vec2 vOcclusionCoord;
      flat out float vTextureId;
      flat out float vTextureAlphaThreshold;
      flat out float vDepthElevation;
      flat out float vOcclusionElevation;
      flat out float vFadeOcclusion;
      flat out float vRadialOcclusion;
      flat out float vVisionOcclusion;
      flat out uint vRestrictionState;

      void main() {
        vec2 vertexPosition;
        vec2 textureCoord;
        _main(vertexPosition, textureCoord);
        vec3 tPos = translationMatrix * vec3(vertexPosition, 1.0);
        gl_Position = vec4((projectionMatrix * tPos).xy, 0.0, 1.0);
        vTextureCoord = textureCoord;
        vOcclusionCoord = tPos.xy / screenDimensions;
        vTextureId = aTextureId;
        vTextureAlphaThreshold = aTextureAlphaThreshold;
        vDepthElevation = aDepthElevation;
        vOcclusionElevation = aOcclusionData.x;
        vFadeOcclusion = aOcclusionData.y;
        vRadialOcclusion = aOcclusionData.z;
        vVisionOcclusion = aOcclusionData.w;
        vRestrictionState = uint(aRestrictionState);
      }
    `;
  }

  /* -------------------------------------------- */

  /**
   * The batch vertex shader source. Subclasses can override it.
   * @type {string}
   * @protected
   */
  static _batchVertexShader = `
    void _main(out vec2 vertexPosition, out vec2 textureCoord) {
      vertexPosition = aVertexPosition;
      textureCoord = aTextureCoord;
    }
  `;

  /* ---------------------------------------- */

  /** @override */
  static get batchFragmentShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_FRAGMENT}

      precision ${PIXI.settings.PRECISION_FRAGMENT} float;

      in vec2 vTextureCoord;
      flat in float vTextureId;

      uniform sampler2D uSamplers[%count%];

      ${DepthSamplerShader.#OPTIONS_CONSTANTS}
      ${this._batchFragmentShader}

      in vec2 vOcclusionCoord;
      flat in float vTextureAlphaThreshold;
      flat in float vDepthElevation;
      flat in float vOcclusionElevation;
      flat in float vFadeOcclusion;
      flat in float vRadialOcclusion;
      flat in float vVisionOcclusion;
      flat in uint vRestrictionState;
      
      uniform sampler2D occlusionTexture;

      out vec3 fragColor;

      void main() {
        float textureAlpha = _main();
        float textureAlphaThreshold = vTextureAlphaThreshold;
        float depthElevation = vDepthElevation;
        float occlusionElevation = vOcclusionElevation;
        float fadeOcclusion = vFadeOcclusion;
        float radialOcclusion = vRadialOcclusion;
        float visionOcclusion = vVisionOcclusion;
        bool restrictsLight = ((vRestrictionState & RESTRICTS_LIGHT) == RESTRICTS_LIGHT);
        bool restrictsWeather = ((vRestrictionState & RESTRICTS_WEATHER) == RESTRICTS_WEATHER);
        ${DepthSamplerShader.#FRAGMENT_MAIN}
      }
    `;
  }

  /* -------------------------------------------- */

  /**
   * The batch fragment shader source. Subclasses can override it.
   * @type {string}
   * @protected
   */
  static _batchFragmentShader = `
    float _main() {
      vec4 color;
      %forloop%
      return color.a;
    }
  `;

  /* -------------------------------------------- */
  /*  Non-Batched version Rendering               */
  /* -------------------------------------------- */

  /** @override */
  static get vertexShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_VERTEX}

      precision ${PIXI.settings.PRECISION_VERTEX} float;

      in vec2 aVertexPosition;
      in vec2 aTextureCoord;

      uniform vec2 screenDimensions;

      ${this._vertexShader}

      uniform mat3 projectionMatrix;

      out vec2 vUvs;
      out vec2 vOcclusionCoord;

      void main() {
        vec2 vertexPosition;
        vec2 textureCoord;
        _main(vertexPosition, textureCoord);
        gl_Position = vec4((projectionMatrix * vec3(vertexPosition, 1.0)).xy, 0.0, 1.0);
        vUvs = textureCoord;
        vOcclusionCoord = vertexPosition / screenDimensions;
      }
    `;
  }

  /* -------------------------------------------- */

  /**
   * The vertex shader source. Subclasses can override it.
   * @type {string}
   * @protected
   */
  static _vertexShader = `
    void _main(out vec2 vertexPosition, out vec2 textureCoord) {
      vertexPosition = aVertexPosition;
      textureCoord = aTextureCoord;
    }
  `;

  /* -------------------------------------------- */

  /** @override */
  static get fragmentShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_FRAGMENT}

      precision ${PIXI.settings.PRECISION_FRAGMENT} float;

      in vec2 vUvs;

      uniform sampler2D sampler;

      ${DepthSamplerShader.#OPTIONS_CONSTANTS}
      ${this._fragmentShader}

      in vec2 vOcclusionCoord;

      uniform sampler2D occlusionTexture;
      uniform float textureAlphaThreshold;
      uniform float depthElevation;
      uniform float occlusionElevation;
      uniform float fadeOcclusion;
      uniform float radialOcclusion;
      uniform float visionOcclusion;
      uniform bool restrictsLight;
      uniform bool restrictsWeather;

      out vec3 fragColor;

      void main() {
        float textureAlpha = _main();
        ${DepthSamplerShader.#FRAGMENT_MAIN}
      }
    `;
  }

  /* -------------------------------------------- */

  /**
   * The fragment shader source. Subclasses can override it.
   * @type {string}
   * @protected
   */
  static _fragmentShader = `
    float _main() {
      return texture(sampler, vUvs).a;
    }
  `;

  /* -------------------------------------------- */

  /** @inheritdoc */
  _preRender(mesh, renderer) {
    super._preRender(mesh, renderer);
    const uniforms = this.uniforms;
    uniforms.screenDimensions = canvas.screenDimensions;
    uniforms.textureAlphaThreshold = mesh.textureAlphaThreshold;
    const occlusionMask = canvas.masks.occlusion;
    uniforms.occlusionTexture = occlusionMask.renderTexture;
    uniforms.occlusionElevation = occlusionMask.mapElevation(mesh.elevation);
    uniforms.depthElevation = canvas.masks.depth.mapElevation(mesh.elevation);
    const occlusionState = mesh._occlusionState;
    uniforms.fadeOcclusion = occlusionState.fade;
    uniforms.radialOcclusion = occlusionState.radial;
    uniforms.visionOcclusion = occlusionState.vision;
    uniforms.restrictsLight = mesh.restrictsLight;
    uniforms.restrictsWeather = mesh.restrictsWeather;
  }

  /* -------------------------------------------- */

  /**
   * The restriction options bit mask constants.
   * @type {string}
   */
  static #OPTIONS_CONSTANTS = foundry.utils.BitMask.generateShaderBitMaskConstants([
    "RESTRICTS_LIGHT",
    "RESTRICTS_WEATHER"
  ]);

  /* -------------------------------------------- */

  /**
   * The fragment source.
   * @type {string}
   */
  static #FRAGMENT_MAIN = `
    float inverseDepthElevation = 1.0 - depthElevation;
    fragColor = vec3(inverseDepthElevation, depthElevation, inverseDepthElevation);
    fragColor *= step(textureAlphaThreshold, textureAlpha);
    vec3 weight = 1.0 - step(occlusionElevation, texture(occlusionTexture, vOcclusionCoord).rgb);
    float occlusion = step(0.5, max(max(weight.r * fadeOcclusion, weight.g * radialOcclusion), weight.b * visionOcclusion));
    fragColor.r *= occlusion;
    fragColor.g *= 1.0 - occlusion;
    fragColor.b *= occlusion;
    if ( !restrictsLight ) {
      fragColor.r = 0.0;
      fragColor.g = 0.0;
    }
    if ( !restrictsWeather ) {
      fragColor.b = 0.0;
    }
  `;
}

/**
 * The batch data that is needed by {@link OccludableSamplerShader} to render an element with batching.
 * @typedef {object} OccludableBatchData
 * @property {PIXI.Texture} _texture                       The texture
 * @property {Float32Array} vertexData                     The vertices
 * @property {Uint16Array|Uint32Array|number[]} indices    The indices
 * @property {Float32Array} uvs                            The texture UVs
 * @property {number} worldAlpha                           The world alpha
 * @property {number} _tintRGB                             The tint
 * @property {number} blendMode                            The blend mode
 * @property {number} elevation                            The elevation
 * @property {number} unoccludedAlpha                      The unoccluded alpha
 * @property {number} occludedAlpha                        The unoccluded alpha
 * @property {number} fadeOcclusion                        The amount of FADE occlusion
 * @property {number} radialOcclusion                      The amount of RADIAL occlusion
 * @property {number} visionOcclusion                      The amount of VISION occlusion
 */

/**
 * The occlusion sampler shader.
 */
class OccludableSamplerShader extends BaseSamplerShader {

  /**
   * The fragment shader code that applies occlusion.
   * @type {string}
   */
  static #OCCLUSION = `
    vec3 occluded = 1.0 - step(occlusionElevation, texture(occlusionTexture, vScreenCoord).rgb);
    float occlusion = max(occluded.r * fadeOcclusion, max(occluded.g * radialOcclusion, occluded.b * visionOcclusion));
    fragColor *= mix(unoccludedAlpha, occludedAlpha, occlusion);
  `;

  /* -------------------------------------------- */
  /*  Batched version Rendering                   */
  /* -------------------------------------------- */

  /** @override */
  static classPluginName = "batchOcclusion";

  /* ---------------------------------------- */

  /** @override */
  static batchGeometry = [
    {id: "aVertexPosition", size: 2, normalized: false, type: PIXI.TYPES.FLOAT},
    {id: "aTextureCoord", size: 2, normalized: false, type: PIXI.TYPES.FLOAT},
    {id: "aColor", size: 4, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE},
    {id: "aTextureId", size: 1, normalized: false, type: PIXI.TYPES.UNSIGNED_SHORT},
    {id: "aOcclusionAlphas", size: 2, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE},
    {id: "aOcclusionData", size: 4, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE}
  ];

  /* -------------------------------------------- */

  /** @override */
  static batchVertexSize = 7;

  /* -------------------------------------------- */

  /** @override */
  static reservedTextureUnits = 1; // We need a texture unit for the occlusion texture

  /* -------------------------------------------- */

  /** @override */
  static defaultUniforms = {
    screenDimensions: [1, 1],
    sampler: null,
    tintAlpha: [1, 1, 1, 1],
    occlusionTexture: null,
    unoccludedAlpha: 1,
    occludedAlpha: 0,
    occlusionElevation: 0,
    fadeOcclusion: 0,
    radialOcclusion: 0,
    visionOcclusion: 0
  };

  /* -------------------------------------------- */

  /** @override */
  static batchDefaultUniforms(maxTex) {
    return {
      screenDimensions: [1, 1],
      occlusionTexture: maxTex
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static _preRenderBatch(batchRenderer) {
    const occlusionMask = canvas.masks.occlusion;
    const uniforms = batchRenderer._shader.uniforms;
    uniforms.screenDimensions = canvas.screenDimensions;
    batchRenderer.renderer.texture.bind(occlusionMask.renderTexture, uniforms.occlusionTexture);
  }

  /* ---------------------------------------- */

  /** @override */
  static _packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex) {
    const {float32View, uint8View, uint16View, uint32View} = attributeBuffer;

    // Write indices into buffer
    const packedVertices = aIndex / this.vertexSize;
    const indices = element.indices;
    for ( let i = 0; i < indices.length; i++ ) {
      indexBuffer[iIndex++] = packedVertices + indices[i];
    }

    // Prepare attributes
    const vertexData = element.vertexData;
    const uvs = element.uvs;
    const baseTexture = element._texture.baseTexture;
    const alpha = Math.min(element.worldAlpha, 1.0);
    const argb = PIXI.Color.shared.setValue(element._tintRGB).toPremultiplied(alpha, baseTexture.alphaMode > 0);
    const textureId = baseTexture._batchLocation;
    const unoccludedAlpha = (element.unoccludedAlpha * 255) | 0;
    const occludedAlpha = (element.occludedAlpha * 255) | 0;
    const occlusionElevation = (canvas.masks.occlusion.mapElevation(element.elevation) * 255) | 0;
    const fadeOcclusion = (element.fadeOcclusion * 255) | 0;
    const radialOcclusion = (element.radialOcclusion * 255) | 0;
    const visionOcclusion = (element.visionOcclusion * 255) | 0;

    // Write attributes into buffer
    const vertexSize = this.vertexSize;
    for ( let i = 0, j = 0; i < vertexData.length; i += 2, j += vertexSize ) {
      let k = aIndex + j;
      float32View[k++] = vertexData[i];
      float32View[k++] = vertexData[i + 1];
      float32View[k++] = uvs[i];
      float32View[k++] = uvs[i + 1];
      uint32View[k++] = argb;
      k <<= 1;
      uint16View[k++] = textureId;
      k <<= 1;
      uint8View[k++] = unoccludedAlpha;
      uint8View[k++] = occludedAlpha;
      uint8View[k++] = occlusionElevation;
      uint8View[k++] = fadeOcclusion;
      uint8View[k++] = radialOcclusion;
      uint8View[k++] = visionOcclusion;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  static get batchVertexShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_VERTEX}

      precision ${PIXI.settings.PRECISION_VERTEX} float;

      in vec2 aVertexPosition;
      in vec2 aTextureCoord;
      in vec4 aColor;

      uniform mat3 translationMatrix;
      uniform vec4 tint;
      uniform vec2 screenDimensions;

      ${this._batchVertexShader}

      in float aTextureId;
      in vec2 aOcclusionAlphas;
      in vec4 aOcclusionData;

      uniform mat3 projectionMatrix;

      out vec2 vTextureCoord;
      out vec2 vScreenCoord;
      flat out vec4 vColor;
      flat out float vTextureId;
      flat out float vUnoccludedAlpha;
      flat out float vOccludedAlpha;
      flat out float vOcclusionElevation;
      flat out float vFadeOcclusion;
      flat out float vRadialOcclusion;
      flat out float vVisionOcclusion;

      void main() {
        vec2 vertexPosition;
        vec2 textureCoord;
        vec4 color;
        _main(vertexPosition, textureCoord, color);
        gl_Position = vec4((projectionMatrix * vec3(vertexPosition, 1.0)).xy, 0.0, 1.0);
        vTextureCoord = textureCoord;
        vScreenCoord = vertexPosition / screenDimensions;
        vColor = color;
        vTextureId = aTextureId;
        vUnoccludedAlpha = aOcclusionAlphas.x;
        vOccludedAlpha = aOcclusionAlphas.y;
        vOcclusionElevation = aOcclusionData.x;
        vFadeOcclusion = aOcclusionData.y;
        vRadialOcclusion = aOcclusionData.z;
        vVisionOcclusion = aOcclusionData.w;
      }
    `;
  }

  /* -------------------------------------------- */

  /**
   * The batch vertex shader source. Subclasses can override it.
   * @type {string}
   * @protected
   */
  static _batchVertexShader = `
    void _main(out vec2 vertexPosition, out vec2 textureCoord, out vec4 color) {
      vertexPosition = (translationMatrix * vec3(aVertexPosition, 1.0)).xy;
      textureCoord = aTextureCoord;
      color = aColor * tint;
    }
  `;

  /* ---------------------------------------- */

  /** @override */
  static get batchFragmentShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_FRAGMENT}

      precision ${PIXI.settings.PRECISION_FRAGMENT} float;

      in vec2 vTextureCoord;
      in vec2 vScreenCoord;
      flat in vec4 vColor;
      flat in float vTextureId;

      uniform sampler2D uSamplers[%count%];

      ${this._batchFragmentShader}

      flat in float vUnoccludedAlpha;
      flat in float vOccludedAlpha;
      flat in float vOcclusionElevation;
      flat in float vFadeOcclusion;
      flat in float vRadialOcclusion;
      flat in float vVisionOcclusion;

      uniform sampler2D occlusionTexture;

      out vec4 fragColor;

      void main() {
        fragColor = _main();
        float unoccludedAlpha = vUnoccludedAlpha;
        float occludedAlpha = vOccludedAlpha;
        float occlusionElevation = vOcclusionElevation;
        float fadeOcclusion = vFadeOcclusion;
        float radialOcclusion = vRadialOcclusion;
        float visionOcclusion = vVisionOcclusion;
        ${OccludableSamplerShader.#OCCLUSION}
      }
    `;
  }

  /* -------------------------------------------- */

  /**
   * The batch fragment shader source. Subclasses can override it.
   * @type {string}
   * @protected
   */
  static _batchFragmentShader = `
    vec4 _main() {
      vec4 color;
      %forloop%
      return color * vColor;
    }
  `;

  /* -------------------------------------------- */
  /*  Non-Batched version Rendering               */
  /* -------------------------------------------- */

  /** @override */
  static get vertexShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_VERTEX}

      precision ${PIXI.settings.PRECISION_VERTEX} float;

      in vec2 aVertexPosition;
      in vec2 aTextureCoord;

      uniform vec2 screenDimensions;

      ${this._vertexShader}

      uniform mat3 projectionMatrix;

      out vec2 vUvs;
      out vec2 vScreenCoord;

      void main() {
        vec2 vertexPosition;
        vec2 textureCoord;
        _main(vertexPosition, textureCoord);
        gl_Position = vec4((projectionMatrix * vec3(vertexPosition, 1.0)).xy, 0.0, 1.0);
        vUvs = textureCoord;
        vScreenCoord = vertexPosition / screenDimensions;
      }
    `;
  }

  /* -------------------------------------------- */

  /**
   * The vertex shader source. Subclasses can override it.
   * @type {string}
   * @protected
   */
  static _vertexShader = `
    void _main(out vec2 vertexPosition, out vec2 textureCoord) {
      vertexPosition = aVertexPosition;
      textureCoord = aTextureCoord;
    }
  `;

  /* -------------------------------------------- */

  /** @override */
  static get fragmentShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_FRAGMENT}

      precision ${PIXI.settings.PRECISION_FRAGMENT} float;

      in vec2 vUvs;
      in vec2 vScreenCoord;

      uniform sampler2D sampler;
      uniform vec4 tintAlpha;

      ${this._fragmentShader}

      uniform sampler2D occlusionTexture;
      uniform float unoccludedAlpha;
      uniform float occludedAlpha;
      uniform float occlusionElevation;
      uniform float fadeOcclusion;
      uniform float radialOcclusion;
      uniform float visionOcclusion;

      out vec4 fragColor;

      void main() {
        fragColor = _main();
        ${OccludableSamplerShader.#OCCLUSION}
      }
    `;
  }

  /* -------------------------------------------- */

  /**
   * The fragment shader source. Subclasses can override it.
   * @type {string}
   * @protected
   */
  static _fragmentShader = `
    vec4 _main() {
      return texture(sampler, vUvs) * tintAlpha;
    }
  `;

  /* -------------------------------------------- */

  /** @inheritdoc */
  _preRender(mesh, renderer) {
    super._preRender(mesh, renderer);
    const uniforms = this.uniforms;
    uniforms.screenDimensions = canvas.screenDimensions;
    const occlusionMask = canvas.masks.occlusion;
    uniforms.occlusionTexture = occlusionMask.renderTexture;
    uniforms.occlusionElevation = occlusionMask.mapElevation(mesh.elevation);
    uniforms.unoccludedAlpha = mesh.unoccludedAlpha;
    uniforms.occludedAlpha = mesh.occludedAlpha;
    const occlusionState = mesh._occlusionState;
    uniforms.fadeOcclusion = occlusionState.fade;
    uniforms.radialOcclusion = occlusionState.radial;
    uniforms.visionOcclusion = occlusionState.vision;
  }
}

/**
 * The base shader class of {@link PrimarySpriteMesh}.
 */
class PrimaryBaseSamplerShader extends OccludableSamplerShader {

  /**
   * The depth shader class associated with this shader.
   * @type {typeof DepthSamplerShader}
   */
  static depthShaderClass = DepthSamplerShader;

  /* -------------------------------------------- */

  /**
   * The depth shader associated with this shader.
   * The depth shader is lazily constructed.
   * @type {DepthSamplerShader}
   */
  get depthShader() {
    return this.#depthShader ??= this.#createDepthShader();
  }

  #depthShader;

  /* -------------------------------------------- */

  /**
   * Create the depth shader and configure it.
   * @returns {DepthSamplerShader}
   */
  #createDepthShader() {
    const depthShader = this.constructor.depthShaderClass.create();
    this._configureDepthShader(depthShader);
    return depthShader;
  }

  /* -------------------------------------------- */

  /**
   * One-time configuration that is called when the depth shader is created.
   * @param {DepthSamplerShader} depthShader    The depth shader
   * @protected
   */
  _configureDepthShader(depthShader) {}
}


/**
 * Compute baseline illumination according to darkness level encoded texture.
 */
class BaselineIlluminationSamplerShader extends BaseSamplerShader {

  /** @override */
  static classPluginName = null;

  /** @inheritdoc */
  static fragmentShader = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;
    uniform sampler2D sampler;
    uniform vec4 tintAlpha;
    uniform vec3 ambientDarkness;
    uniform vec3 ambientDaylight;
    varying vec2 vUvs;    

    void main() {
      float illuminationRed = texture2D(sampler, vUvs).r;
      vec3 finalColor = mix(ambientDaylight, ambientDarkness, illuminationRed);
      gl_FragColor = vec4(finalColor, 1.0) * tintAlpha;
    }`;

  /** @inheritdoc */
  static defaultUniforms = {
    tintAlpha: [1, 1, 1, 1],
    ambientDarkness: [0, 0, 0],
    ambientDaylight: [1, 1, 1],
    sampler: null
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  _preRender(mesh, renderer) {
    super._preRender(mesh, renderer);
    const c = canvas.colors;
    const u = this.uniforms;
    c.ambientDarkness.applyRGB(u.ambientDarkness);
    c.ambientDaylight.applyRGB(u.ambientDaylight);
  }
}

/**
 * A color adjustment shader.
 */
class ColorAdjustmentsSamplerShader extends BaseSamplerShader {

  /** @override */
  static classPluginName = null;

  /* -------------------------------------------- */

  /** @override */
  static vertexShader = `
    precision ${PIXI.settings.PRECISION_VERTEX} float;
    attribute vec2 aVertexPosition;
    attribute vec2 aTextureCoord;
    uniform mat3 projectionMatrix;
    uniform vec2 screenDimensions;
    varying vec2 vUvs;
    varying vec2 vScreenCoord;

    void main() {
      gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
      vUvs = aTextureCoord;
      vScreenCoord = aVertexPosition / screenDimensions;
    }`;

  /* -------------------------------------------- */

  /** @override */
  static fragmentShader = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;
    uniform sampler2D sampler;
    uniform vec4 tintAlpha;
    uniform vec3 tint;
    uniform float exposure;
    uniform float contrast;
    uniform float saturation;
    uniform float brightness;
    uniform sampler2D darknessLevelTexture;
    uniform bool linkedToDarknessLevel;
    varying vec2 vUvs;
    varying vec2 vScreenCoord;

    ${this.CONSTANTS}
    ${this.PERCEIVED_BRIGHTNESS}

    void main() {
      vec4 baseColor = texture2D(sampler, vUvs);

      if ( baseColor.a > 0.0 ) {
        // Unmultiply rgb with alpha channel
        baseColor.rgb /= baseColor.a;

        // Copy original color before update
        vec3 originalColor = baseColor.rgb;

        ${this.ADJUSTMENTS}

        if ( linkedToDarknessLevel ) {
          float darknessLevel = texture2D(darknessLevelTexture, vScreenCoord).r;
          baseColor.rgb = mix(originalColor, baseColor.rgb, darknessLevel);
        }

        // Multiply rgb with tint and alpha channel
        baseColor.rgb *= (tint * baseColor.a);
      }

      // Output with tint and alpha
      gl_FragColor = baseColor * tintAlpha;
    }`;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static defaultUniforms = {
    tintAlpha: [1, 1, 1, 1],
    tint: [1, 1, 1],
    contrast: 0,
    saturation: 0,
    exposure: 0,
    sampler: null,
    linkedToDarknessLevel: false,
    darknessLevelTexture: null,
    screenDimensions: [1, 1]
  };

  /* -------------------------------------------- */

  get linkedToDarknessLevel() {
    return this.uniforms.linkedToDarknessLevel;
  }

  set linkedToDarknessLevel(link) {
    this.uniforms.linkedToDarknessLevel = link;
  }

  /* -------------------------------------------- */

  get contrast() {
    return this.uniforms.contrast;
  }

  set contrast(contrast) {
    this.uniforms.contrast = contrast;
  }

  /* -------------------------------------------- */

  get exposure() {
    return this.uniforms.exposure;
  }

  set exposure(exposure) {
    this.uniforms.exposure = exposure;
  }

  /* -------------------------------------------- */

  get saturation() {
    return this.uniforms.saturation;
  }

  set saturation(saturation) {
    this.uniforms.saturation = saturation;
  }
}

/* -------------------------------------------- */

/**
 * A light amplification shader.
 */
class AmplificationSamplerShader extends ColorAdjustmentsSamplerShader {

  /** @override */
  static classPluginName = null;

  /* -------------------------------------------- */

  /** @override */
  static vertexShader = `
    precision ${PIXI.settings.PRECISION_VERTEX} float;
    attribute vec2 aVertexPosition;
    attribute vec2 aTextureCoord;
    uniform mat3 projectionMatrix;
    uniform vec2 screenDimensions;
    varying vec2 vUvs;
    varying vec2 vScreenCoord;

    void main() {
      gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
      vUvs = aTextureCoord;
      vScreenCoord = aVertexPosition / screenDimensions;
    }
  `;

  /* -------------------------------------------- */

  /** @override */
  static fragmentShader = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;
    uniform sampler2D sampler;
    uniform vec4 tintAlpha;
    uniform vec3 tint;
    uniform float exposure;
    uniform float contrast;
    uniform float saturation;
    uniform float brightness;
    uniform sampler2D darknessLevelTexture;
    uniform bool linkedToDarknessLevel;
    uniform bool enable;
    varying vec2 vUvs;
    varying vec2 vScreenCoord;

    ${this.CONSTANTS}
    ${this.PERCEIVED_BRIGHTNESS}

    void main() {
      vec4 baseColor = texture2D(sampler, vUvs);

      if ( enable && baseColor.a > 0.0 ) {
        // Unmultiply rgb with alpha channel
        baseColor.rgb /= baseColor.a;

        float lum = perceivedBrightness(baseColor.rgb);
        vec3 vision = vec3(smoothstep(0.0, 1.0, lum * 1.5)) * tint;
        float darknessLevel = texture2D(darknessLevelTexture, vScreenCoord).r;
        baseColor.rgb = vision + (vision * (lum + brightness) * 0.1) + (baseColor.rgb * (1.0 - darknessLevel) * 0.125);

        ${this.ADJUSTMENTS}

        // Multiply rgb with alpha channel
        baseColor.rgb *= baseColor.a;
      }

      // Output with tint and alpha
      gl_FragColor = baseColor * tintAlpha;
    }`;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static defaultUniforms = {
    tintAlpha: [1, 1, 1, 1],
    tint: [0.38, 0.8, 0.38],
    brightness: 0,
    darknessLevelTexture: null,
    screenDimensions: [1, 1],
    enable: true
  };

  /* -------------------------------------------- */

  /**
   * Brightness controls the luminosity.
   * @type {number}
   */
  get brightness() {
    return this.uniforms.brightness;
  }

  set brightness(brightness) {
    this.uniforms.brightness = brightness;
  }

  /* -------------------------------------------- */

  /**
   * Tint color applied to Light Amplification.
   * @type {number[]}       Light Amplification tint (default: [0.48, 1.0, 0.48]).
   */
  get colorTint() {
    return this.uniforms.colorTint;
  }

  set colorTint(color) {
    this.uniforms.colorTint = color;
  }
}

/**
 * A simple shader which purpose is to make the original texture red channel the alpha channel,
 * and still keeping channel informations. Used in cunjunction with the AlphaBlurFilterPass and Fog of War.
 */
class FogSamplerShader extends BaseSamplerShader {
  /** @override */
  static classPluginName = null;

  /** @override */
  static fragmentShader = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;
    uniform sampler2D sampler;
    uniform vec4 tintAlpha;
    varying vec2 vUvs;
    void main() {
        vec4 color = texture2D(sampler, vUvs);
        gl_FragColor = vec4(1.0, color.gb, 1.0) * step(0.15, color.r) * tintAlpha;
    }`;
}

/**
 * The shader definition which powers the TokenRing.
 */
class TokenRingSamplerShader extends PrimaryBaseSamplerShader {

  /** @override */
  static classPluginName = "tokenRingBatch";

  /* -------------------------------------------- */

  /** @override */
  static pausable = false;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static batchGeometry = [
    ...(super.batchGeometry ?? []),
    {id: "aRingTextureCoord", size: 2, normalized: false, type: PIXI.TYPES.FLOAT},
    {id: "aBackgroundTextureCoord", size: 2, normalized: false, type: PIXI.TYPES.FLOAT},
    {id: "aRingColor", size: 4, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE},
    {id: "aBackgroundColor", size: 4, normalized: true, type: PIXI.TYPES.UNSIGNED_BYTE},
    {id: "aStates", size: 1, normalized: false, type: PIXI.TYPES.FLOAT},
    {id: "aScaleCorrection", size: 2, normalized: false, type: PIXI.TYPES.FLOAT},
    {id: "aRingColorBand", size: 2, normalized: false, type: PIXI.TYPES.FLOAT},
    {id: "aTextureScaleCorrection", size: 1, normalized: false, type: PIXI.TYPES.FLOAT}
  ];

  /* -------------------------------------------- */

  /** @inheritdoc */
  static batchVertexSize = super.batchVertexSize + 12;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static reservedTextureUnits = super.reservedTextureUnits + 1;

  /* -------------------------------------------- */

  /**
   * A null UVs array used for nulled texture position.
   * @type {Float32Array}
   */
  static nullUvs = new Float32Array([0, 0, 0, 0, 0, 0, 0, 0]);

  /* -------------------------------------------- */

  /** @inheritdoc */
  static batchDefaultUniforms(maxTex) {
    return {
      ...super.batchDefaultUniforms(maxTex),
      tokenRingTexture: maxTex + super.reservedTextureUnits,
      time: 0
    };
  }

  /* -------------------------------------------- */

  /** @override */
  static _preRenderBatch(batchRenderer) {
    super._preRenderBatch(batchRenderer);
    batchRenderer.renderer.texture.bind(CONFIG.Token.ring.ringClass.baseTexture,
      batchRenderer.uniforms.tokenRingTexture);
    batchRenderer.uniforms.time = canvas.app.ticker.lastTime / 1000;
    batchRenderer.uniforms.debugColorBands = CONFIG.Token.ring.debugColorBands;
  }

  /* ---------------------------------------- */

  /** @inheritdoc */
  static _packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex) {
    super._packInterleavedGeometry(element, attributeBuffer, indexBuffer, aIndex, iIndex);
    const {float32View, uint32View} = attributeBuffer;

    // Prepare token ring attributes
    const vertexData = element.vertexData;
    const trConfig = CONFIG.Token.ringClass;
    const object = element.object.object || {};
    const ringColor = PIXI.Color.shared.setValue(object.ring?.ringColorLittleEndian ?? 0xFFFFFF).toNumber();
    const bkgColor = PIXI.Color.shared.setValue(object.ring?.bkgColorLittleEndian ?? 0xFFFFFF).toNumber();
    const ringUvsFloat = object.ring?.ringUVs ?? trConfig.tokenRingSamplerShader.nullUvs;
    const bkgUvsFloat = object.ring?.bkgUVs ?? trConfig.tokenRingSamplerShader.nullUvs;
    const states = (object.ring?.effects ?? 0) + 0.5;
    const scaleCorrectionX = (object.ring?.scaleCorrection ?? 1) * (object.ring?.scaleAdjustmentX ?? 1);
    const scaleCorrectionY = (object.ring?.scaleCorrection ?? 1) * (object.ring?.scaleAdjustmentY ?? 1);
    const colorBandRadiusStart = object.ring?.colorBand.startRadius ?? 0;
    const colorBandRadiusEnd = object.ring?.colorBand.endRadius ?? 0;
    const textureScaleAdjustment = object.ring?.textureScaleAdjustment ?? 1;

    // Write attributes into buffer
    const vertexSize = this.vertexSize;
    const attributeOffset = PrimaryBaseSamplerShader.batchVertexSize;
    for ( let i = 0, j = attributeOffset; i < vertexData.length; i += 2, j += vertexSize ) {
      let k = aIndex + j;
      float32View[k++] = ringUvsFloat[i];
      float32View[k++] = ringUvsFloat[i + 1];
      float32View[k++] = bkgUvsFloat[i];
      float32View[k++] = bkgUvsFloat[i + 1];
      uint32View[k++] = ringColor;
      uint32View[k++] = bkgColor;
      float32View[k++] = states;
      float32View[k++] = scaleCorrectionX;
      float32View[k++] = scaleCorrectionY;
      float32View[k++] = colorBandRadiusStart;
      float32View[k++] = colorBandRadiusEnd;
      float32View[k++] = textureScaleAdjustment;
    }
  }

  /* ---------------------------------------- */
  /*  GLSL Shader Code                        */
  /* ---------------------------------------- */

  /**
   * The fragment shader header.
   * @type {string}
   */
  static #FRAG_HEADER = `
    const uint STATE_RING_PULSE = 0x02U;
    const uint STATE_RING_GRADIENT = 0x04U;
    const uint STATE_BKG_WAVE = 0x08U;
    const uint STATE_INVISIBLE = 0x10U;

    /* -------------------------------------------- */

    bool hasState(in uint state) {
      return (vStates & state) == state;
    }

    /* -------------------------------------------- */

    vec2 rotation(in vec2 uv, in float a) {
      uv -= 0.5;
      float s = sin(a);
      float c = cos(a);
      return uv * mat2(c, -s, s, c) + 0.5;
    }

    /* -------------------------------------------- */

    float normalizedCos(in float val) {
      return (cos(val) + 1.0) * 0.5;
    }

    /* -------------------------------------------- */

    float wave(in float dist) {
      float sinWave = 0.5 * (sin(-time * 4.0 + dist * 100.0) + 1.0);
      return mix(1.0, 0.55 * sinWave + 0.8, clamp(1.0 - dist, 0.0, 1.0));
    }

    /* -------------------------------------------- */

    vec4 colorizeTokenRing(in vec4 tokenRing, in float dist) {
      if ( tokenRing.a > 0.0 ) tokenRing.rgb /= tokenRing.a;
      vec3 rcol = hasState(STATE_RING_PULSE)
                  ? mix(tokenRing.rrr, tokenRing.rrr * 0.35, (cos(time * 2.0) + 1.0) * 0.5)
                  : tokenRing.rrr;
      vec3 ccol = vRingColor * rcol;
      vec3 gcol = hasState(STATE_RING_GRADIENT)
              ? mix(ccol, vBackgroundColor * tokenRing.r, smoothstep(0.0, 1.0, dot(rotation(vTextureCoord, time), vec2(0.5))))
              : ccol;
      vec3 col = mix(tokenRing.rgb, gcol, step(vRingColorBand.x, dist) - step(vRingColorBand.y, dist));
      return vec4(col, 1.0) * tokenRing.a;
    }

    /* -------------------------------------------- */

    vec4 colorizeTokenBackground(in vec4 tokenBackground, in float dist) {
      if (tokenBackground.a > 0.0) tokenBackground.rgb /= tokenBackground.a;
    
      float wave = hasState(STATE_BKG_WAVE) ? (0.5 + wave(dist) * 1.5) : 1.0;
      vec3 bgColor = tokenBackground.rgb;
      vec3 tintColor = vBackgroundColor.rgb;
      vec3 resultColor;
  
      // Overlay blend mode
      if ( tintColor == vec3(1.0, 1.0, 1.0) ) {
        // If tint color is pure white, keep the original background color
        resultColor = bgColor;
      } else {
        // Overlay blend mode
        for ( int i = 0; i < 3; i++ ) {
          if ( bgColor[i] < 0.5 ) resultColor[i] = 2.0 * bgColor[i] * tintColor[i];
          else resultColor[i] = 1.0 - 2.0 * (1.0 - bgColor[i]) * (1.0 - tintColor[i]);
        }
      }
      return vec4(resultColor, 1.0) * tokenBackground.a * wave;
    }

    /* -------------------------------------------- */

    vec4 processTokenColor(in vec4 finalColor) {
      if ( !hasState(STATE_INVISIBLE) ) return finalColor;

      // Computing halo
      float lum = perceivedBrightness(finalColor.rgb);
      vec3 haloColor = vec3(lum) * vec3(0.5, 1.0, 1.0);

      // Construct final image
      return vec4(haloColor, 1.0) * finalColor.a
                   * (0.55 + normalizedCos(time * 2.0) * 0.25);
    }

    /* -------------------------------------------- */

    vec4 blend(vec4 src, vec4 dst) {
      return src + (dst * (1.0 - src.a));
    }
    
    /* -------------------------------------------- */
    
    float getTokenTextureClip() {
      return step(3.5,
           step(0.0, vTextureCoord.x) +
           step(0.0, vTextureCoord.y) +
           step(vTextureCoord.x, 1.0) +
           step(vTextureCoord.y, 1.0));
    }
  `;

  /* ---------------------------------------- */

  /**
   * Fragment shader body.
   * @type {string}
   */
  static #FRAG_MAIN = `
    vec4 color;
    vec4 result;

    %forloop%

    if ( vStates == 0U ) result = color * vColor;
    else {
      // Compute distances
      vec2 scaledDistVec = (vOrigTextureCoord - 0.5) * 2.0 * vScaleCorrection;
      
      // Euclidean distance computation
      float dist = length(scaledDistVec);
      
      // Rectangular distance computation
      vec2 absScaledDistVec = abs(scaledDistVec);
      float rectangularDist = max(absScaledDistVec.x, absScaledDistVec.y);
      
      // Clip token texture color (necessary when a mesh is padded on x and/or y axis)
      color *= getTokenTextureClip();
      
      // Blend token texture, token ring and token background
      result = blend(
        processTokenColor(color * (vColor / vColor.a)),
        blend(
          colorizeTokenRing(texture(tokenRingTexture, vRingTextureCoord), dist),
          colorizeTokenBackground(texture(tokenRingTexture, vBackgroundTextureCoord), dist)
        ) * step(rectangularDist, 1.0)
      ) * vColor.a;
    }
  `;

  /* ---------------------------------------- */

  /**
   * Fragment shader body for debug code.
   * @type {string}
   */
  static #FRAG_MAIN_DEBUG = `
    if ( debugColorBands ) {
      vec2 scaledDistVec = (vTextureCoord - 0.5) * 2.0 * vScaleCorrection;
      float dist = length(scaledDistVec);
      result.rgb += vec3(0.0, 0.5, 0.0) * (step(vRingColorBand.x, dist) - step(vRingColorBand.y, dist));
    }
  `;

  /* ---------------------------------------- */

  /** @override */
  static _batchVertexShader = `
    in vec2 aRingTextureCoord;
    in vec2 aBackgroundTextureCoord;
    in vec2 aScaleCorrection;
    in vec2 aRingColorBand;
    in vec4 aRingColor;
    in vec4 aBackgroundColor;
    in float aTextureScaleCorrection;
    in float aStates;

    out vec2 vRingTextureCoord;
    out vec2 vBackgroundTextureCoord;
    out vec2 vOrigTextureCoord;
    flat out vec2 vRingColorBand;
    flat out vec3 vRingColor;
    flat out vec3 vBackgroundColor;
    flat out vec2 vScaleCorrection;
    flat out uint vStates;

    void _main(out vec2 vertexPosition, out vec2 textureCoord, out vec4 color) {
      vRingTextureCoord = aRingTextureCoord;
      vBackgroundTextureCoord = aBackgroundTextureCoord;
      vRingColor = aRingColor.rgb;
      vBackgroundColor = aBackgroundColor.rgb;
      vStates = uint(aStates);
      vScaleCorrection = aScaleCorrection;
      vRingColorBand = aRingColorBand;
      vOrigTextureCoord = aTextureCoord;
      vertexPosition = (translationMatrix * vec3(aVertexPosition, 1.0)).xy;
      textureCoord = (aTextureCoord - 0.5) * aTextureScaleCorrection + 0.5;
      color = aColor * tint;
    }
  `;

  /* -------------------------------------------- */

  /** @override */
  static _batchFragmentShader = `
    in vec2 vRingTextureCoord;
    in vec2 vBackgroundTextureCoord;
    in vec2 vOrigTextureCoord;
    flat in vec3 vRingColor;
    flat in vec3 vBackgroundColor;
    flat in vec2 vScaleCorrection;
    flat in vec2 vRingColorBand;
    flat in uint vStates;

    uniform sampler2D tokenRingTexture;
    uniform float time;
    uniform bool debugColorBands;

    ${this.CONSTANTS}
    ${this.PERCEIVED_BRIGHTNESS}
    ${TokenRingSamplerShader.#FRAG_HEADER}

    vec4 _main() {
      ${TokenRingSamplerShader.#FRAG_MAIN}
      ${TokenRingSamplerShader.#FRAG_MAIN_DEBUG}
      return result;
    }
  `;
}

/**
 * Bewitching Wave animation illumination shader
 */
class BewitchingWaveIlluminationShader extends AdaptiveIlluminationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBM(4, 1.0)}
  ${this.PERCEIVED_BRIGHTNESS}

  // Transform UV
  vec2 transform(in vec2 uv, in float dist) {
    float t = time * 0.25;
    mat2 rotmat = mat2(cos(t), -sin(t), sin(t), cos(t));
    mat2 scalemat = mat2(2.5, 0.0, 0.0, 2.5);
    uv -= vec2(0.5); 
    uv *= rotmat * scalemat;
    uv += vec2(0.5);
    return uv;
  }

  float bwave(in float dist) {
    vec2 uv = transform(vUvs, dist);
    float motion = fbm(uv + time * 0.25);
    float distortion = mix(1.0, motion, clamp(1.0 - dist, 0.0, 1.0));
    float sinWave = 0.5 * (sin(-time * 6.0 + dist * 10.0 * intensity * distortion) + 1.0);
    return 0.3 * sinWave + 0.8;
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    ${this.TRANSITION}
    finalColor *= bwave(dist);
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/* -------------------------------------------- */

/**
 * Bewitching Wave animation coloration shader
 */
class BewitchingWaveColorationShader extends AdaptiveColorationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBM(4, 1.0)}
  ${this.PERCEIVED_BRIGHTNESS}

  // Transform UV
  vec2 transform(in vec2 uv, in float dist) {
    float t = time * 0.25;
    mat2 rotmat = mat2(cos(t), -sin(t), sin(t), cos(t));
    mat2 scalemat = mat2(2.5, 0.0, 0.0, 2.5);
    uv -= vec2(0.5); 
    uv *= rotmat * scalemat;
    uv += vec2(0.5);
    return uv;
  }

  float bwave(in float dist) {
    vec2 uv = transform(vUvs, dist);
    float motion = fbm(uv + time * 0.25);
    float distortion = mix(1.0, motion, clamp(1.0 - dist, 0.0, 1.0));
    float sinWave = 0.5 * (sin(-time * 6.0 + dist * 10.0 * intensity * distortion) + 1.0);
    return 0.55 * sinWave + 0.8;
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    finalColor = color * bwave(dist) * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Black Hole animation illumination shader
 */
class BlackHoleDarknessShader extends AdaptiveDarknessShader {

  /* -------------------------------------------- */
  /*  GLSL Statics                                */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBMHQ()}
  ${this.PERCEIVED_BRIGHTNESS}

  // create an emanation composed of n beams, n = intensity
  vec3 beamsEmanation(in vec2 uv, in float dist, in vec3 pCol) {   
    float angle = atan(uv.x, uv.y) * INVTWOPI;

    // Create the beams
    float dad = mix(0.33, 5.0, dist);
    float beams = fract(angle + sin(dist * 30.0 * (intensity * 0.2) - time + fbm(uv * 10.0 + time * 0.25, 1.0) * dad));

    // Compose the final beams and reverse beams, to get a nice gradient on EACH side of the beams.
    beams = max(beams, 1.0 - beams);

    // Creating the effect
    return smoothstep(0.0, 1.1 + (intensity * 0.1), beams * pCol);
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    vec2 uvs = (2.0 * vUvs) - 1.0;
    finalColor *= (mix(color, color * 0.66, darknessLevel) * colorationAlpha);
    float rd = pow(1.0 - dist, 3.0);
    finalColor = beamsEmanation(uvs, rd, finalColor);
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Chroma animation coloration shader
 */
class ChromaColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.HSB2RGB}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}
    finalColor = mix( color, 
                      hsb2rgb(vec3(time * 0.25, 1.0, 1.0)),
                      intensity * 0.1 ) * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Emanation animation coloration shader
 */
class EmanationColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  // Create an emanation composed of n beams, n = intensity
  vec3 beamsEmanation(in vec2 uv, in float dist) {
    float angle = atan(uv.x, uv.y) * INVTWOPI;

    // create the beams
    float beams = fract( angle * intensity + sin(dist * 10.0 - time));

    // compose the final beams with max, to get a nice gradient on EACH side of the beams.
    beams = max(beams, 1.0 - beams);

    // creating the effect : applying color and color correction. saturate the entire output color.
    return smoothstep( 0.0, 1.0, beams * color);
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    vec2 uvs = (2.0 * vUvs) - 1.0;
    // apply beams emanation, fade and alpha
    finalColor = beamsEmanation(uvs, dist) * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Energy field animation coloration shader
 */
class EnergyFieldColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `    
  ${this.SHADER_HEADER}
  ${this.PRNG3D}
  ${this.PERCEIVED_BRIGHTNESS}

  // classic 3d voronoi (with some bug fixes)
  vec3 voronoi3d(const in vec3 x) {
    vec3 p = floor(x);
    vec3 f = fract(x);
    
    float id = 0.0;
    vec2 res = vec2(100.0);
    
    for (int k = -1; k <= 1; k++) {
      for (int j = -1; j <= 1; j++) {
        for (int i = -1; i <= 1; i++) {
          vec3 b = vec3(float(i), float(j), float(k));
          vec3 r = vec3(b) - f + random(p + b);
          
          float d = dot(r, r);
          float cond = max(sign(res.x - d), 0.0);
          float nCond = 1.0 - cond;
          float cond2 = nCond * max(sign(res.y - d), 0.0);
          float nCond2 = 1.0 - cond2;
    
          id = (dot(p + b, vec3(1.0, 67.0, 142.0)) * cond) + (id * nCond);
          res = vec2(d, res.x) * cond + res * nCond;
    
          res.y = cond2 * d + nCond2 * res.y;
        }
      }
    }
    // replaced abs(id) by pow( abs(id + 10.0), 0.01)
    // needed to remove artifacts in some specific configuration
    return vec3( sqrt(res), pow( abs(id + 10.0), 0.01) );
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    vec2 uv = vUvs;
    
    // Hemispherize and scaling the uv
    float f = (1.0 - sqrt(1.0 - dist)) / dist;
    uv -= vec2(0.5);
    uv *= f * 4.0 * intensity;
    uv += vec2(0.5);
    
    // time and uv motion variables
    float t = time * 0.4;
    float uvx = cos(uv.x - t);
    float uvy = cos(uv.y + t);
    float uvxt = cos(uv.x + sin(t));
    float uvyt = sin(uv.y + cos(t));
    
    // creating the voronoi 3D sphere, applying motion
    vec3 c = voronoi3d(vec3(uv.x - uvx + uvyt, 
                            mix(uv.x, uv.y, 0.5) + uvxt - uvyt + uvx,
                            uv.y + uvxt - uvx));
    
    // applying color and contrast, to create sharp black areas. 
    finalColor = c.x * c.x * c.x * color * colorationAlpha;

    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Fairy light animation coloration shader
 */
class FairyLightColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.HSB2RGB}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBM(3, 1.0)}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}
    
    // Creating distortion with vUvs and fbm
    float distortion1 = fbm(vec2( 
                        fbm(vUvs * 3.0 + time * 0.50), 
                        fbm((-vUvs + vec2(1.)) * 5.0 + time * INVTHREE)));
    
    float distortion2 = fbm(vec2(
                        fbm(-vUvs * 3.0 + time * 0.50),
                        fbm((-vUvs + vec2(1.)) * 5.0 - time * INVTHREE)));
    vec2 uv = vUvs;
      
    // time related var
    float t = time * 0.5;
    float tcos = 0.5 * (0.5 * (cos(t)+1.0)) + 0.25;
    float tsin = 0.5 * (0.5 * (sin(t)+1.0)) + 0.25;
    
    // Creating distortions with cos and sin : create fluidity
    uv -= PIVOT;
    uv *= tcos * distortion1;
    uv *= tsin * distortion2;
    uv *= fbm(vec2(time + distortion1, time + distortion2));
    uv += PIVOT;

    // Creating the rainbow
    float intens = intensity * 0.1;
    vec2 nuv = vUvs * 2.0 - 1.0;
    vec2 puv = vec2(atan(nuv.x, nuv.y) * INVTWOPI + 0.5, length(nuv));
    vec3 rainbow = hsb2rgb(vec3(puv.x + puv.y - time * 0.2, 1.0, 1.0));
    vec3 mixedColor = mix(color, rainbow, smoothstep(0.0, 1.5 - intens, dist));

    finalColor = distortion1 * distortion1 * 
                 distortion2 * distortion2 * 
                 mixedColor * colorationAlpha * (1.0 - dist * dist * dist) *
                 mix( uv.x + distortion1 * 4.5 * (intensity * 0.4),
                      uv.y + distortion2 * 4.5 * (intensity * 0.4), tcos);
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/* -------------------------------------------- */

/**
 * Fairy light animation illumination shader
 */
class FairyLightIlluminationShader extends AdaptiveIlluminationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBM(3, 1.0)}

  void main() {
    ${this.FRAGMENT_BEGIN}
    
    // Creating distortion with vUvs and fbm
    float distortion1 = fbm(vec2( 
                        fbm(vUvs * 3.0 - time * 0.50), 
                        fbm((-vUvs + vec2(1.)) * 5.0 + time * INVTHREE)));
    
    float distortion2 = fbm(vec2(
                        fbm(-vUvs * 3.0 - time * 0.50),
                        fbm((-vUvs + vec2(1.)) * 5.0 - time * INVTHREE)));
      
    // linear interpolation motion
    float motionWave = 0.5 * (0.5 * (cos(time * 0.5) + 1.0)) + 0.25;
    ${this.TRANSITION}
    finalColor *= mix(distortion1, distortion2, motionWave);
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Alternative torch illumination shader
 */
class FlameIlluminationShader extends AdaptiveIlluminationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}
  
  void main() {
    ${this.FRAGMENT_BEGIN}                          
    ${this.TRANSITION}
    finalColor *= brightnessPulse;
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;

  /** @inheritdoc */
  static defaultUniforms = ({...super.defaultUniforms, brightnessPulse: 1});
}

/* -------------------------------------------- */

/**
 * Alternative torch coloration shader
 */
class FlameColorationShader extends AdaptiveColorationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBMHQ(3)}
  ${this.PERCEIVED_BRIGHTNESS}

  vec2 scale(in vec2 uv, in float scale) {
    mat2 scalemat = mat2(scale, 0.0, 0.0, scale);
    uv -= PIVOT; 
    uv *= scalemat;
    uv += PIVOT;
    return uv;
  }
  
  void main() {
    ${this.FRAGMENT_BEGIN}
    vec2 uv = scale(vUvs, 10.0 * ratio);
    
    float intens = pow(0.1 * intensity, 2.0);
    float fratioInner = ratio * (intens * 0.5) - 
                   (0.005 * 
                        fbm( vec2( 
                             uv.x + time * 8.01, 
                             uv.y + time * 10.72), 1.0));
    float fratioOuter = ratio - (0.007 * 
                        fbm( vec2( 
                             uv.x + time * 7.04, 
                             uv.y + time * 9.51), 2.0));
                             
    float fdist = max(dist - fratioInner * intens, 0.0);
    
    float flameDist = smoothstep(clamp(0.97 - fratioInner, 0.0, 1.0),
                                 clamp(1.03 - fratioInner, 0.0, 1.0),
                                 1.0 - fdist);
    float flameDistInner = smoothstep(clamp(0.95 - fratioOuter, 0.0, 1.0),
                                      clamp(1.05 - fratioOuter, 0.0, 1.0),
                                      1.0 - fdist);
                                 
    vec3 flameColor = color * 8.0;
    vec3 flameFlickerColor = color * 1.2;
    
    finalColor = mix(mix(color, flameFlickerColor, flameDistInner),
                     flameColor, 
                     flameDist) * brightnessPulse * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }
  `;

  /** @inheritdoc */
  static defaultUniforms = ({ ...super.defaultUniforms, brightnessPulse: 1});
}

/**
 * Fog animation coloration shader
 */
class FogColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBM(4, 1.0)}
  ${this.PERCEIVED_BRIGHTNESS}

  vec3 fog() {
    // constructing the palette
    vec3 c1 = color * 0.60;
    vec3 c2 = color * 0.95;
    vec3 c3 = color * 0.50;
    vec3 c4 = color * 0.75;
    vec3 c5 = vec3(0.3);
    vec3 c6 = color;
    
    // creating the deformation
    vec2 uv = vUvs;
    vec2 p = uv.xy * 8.0;

    // time motion fbm and palette mixing
    float q = fbm(p - time * 0.1);
    vec2 r = vec2(fbm(p + q - time * 0.5 - p.x - p.y), 
                  fbm(p + q - time * 0.3));
    vec3 c = clamp(mix(c1, 
                       c2, 
                       fbm(p + r)) + mix(c3, c4, r.x) 
                                   - mix(c5, c6, r.y),
                                     vec3(0.0), vec3(1.0));
    // returning the color
    return c;
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    float intens = intensity * 0.2;
    // applying fog
    finalColor = fog() * intens * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * A futuristic Force Grid animation.
 */
class ForceGridColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  const float MAX_INTENSITY = 1.2;
  const float MIN_INTENSITY = 0.8;

  vec2 hspherize(in vec2 uv, in float dist) {
    float f = (1.0 - sqrt(1.0 - dist)) / dist;
    uv -= vec2(0.50);
    uv *= f * 5.0;
    uv += vec2(0.5);
    return uv;
  }

  float wave(in float dist) {
    float sinWave = 0.5 * (sin(time * 6.0 + pow(1.0 - dist, 0.10) * 35.0 * intensity) + 1.0);
    return ((MAX_INTENSITY - MIN_INTENSITY) * sinWave) + MIN_INTENSITY;
  }

  float fpert(in float d, in float p) {
    return max(0.3 - 
               mod(p + time + d * 0.3, 3.5),
               0.0) * intensity * 2.0;
  }

  float pert(in vec2 uv, in float dist, in float d, in float w) {
    uv -= vec2(0.5);
    float f = fpert(d, min( uv.y,  uv.x)) +
              fpert(d, min(-uv.y,  uv.x)) +
              fpert(d, min(-uv.y, -uv.x)) +
              fpert(d, min( uv.y, -uv.x));
    f *= f;
    return max(f, 3.0 - f) * w;
  }

  vec3 forcegrid(vec2 suv, in float dist) {
    vec2 uv = suv - vec2(0.2075, 0.2075);
    vec2 cid2 = floor(uv);
    float cid = (cid2.y + cid2.x);
    uv = fract(uv);
    float r = 0.3;
    float d = 1.0;
    float e;
    float c;

    for( int i = 0; i < 5; i++ ) {
      e = uv.x - r;
      c = clamp(1.0 - abs(e * 0.75), 0.0, 1.0);
      d += pow(c, 200.0) * (1.0 - dist);
      if ( e > 0.0 ) {
        uv.x = (uv.x - r) / (2.0 - r);
      } 
      uv = uv.yx;
    }

    float w = wave(dist);
    vec3 col = vec3(max(d - 1.0, 0.0)) * 1.8;
    col *= pert(suv, dist * intensity * 4.0, d, w);
    col += color * 0.30 * w;
    return col * color;
  }
  
  void main() {
    ${this.FRAGMENT_BEGIN}
    vec2 uvs = vUvs;
    uvs -= PIVOT;
    uvs *= intensity * 0.2;
    uvs += PIVOT;
    vec2 suvs = hspherize(uvs, dist);
    finalColor = forcegrid(suvs, dist) * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }
  `;
}

/**
 * Ghost light animation illumination shader
 */
class GhostLightIlluminationShader extends AdaptiveIlluminationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBM(3, 1.0)}

  void main() {
    ${this.FRAGMENT_BEGIN}
    
    // Creating distortion with vUvs and fbm
    float distortion1 = fbm(vec2( 
                        fbm(vUvs * 5.0 - time * 0.50), 
                        fbm((-vUvs - vec2(0.01)) * 5.0 + time * INVTHREE)));
    
    float distortion2 = fbm(vec2(
                        fbm(-vUvs * 5.0 - time * 0.50),
                        fbm((-vUvs + vec2(0.01)) * 5.0 + time * INVTHREE)));
    vec2 uv = vUvs;
      
    // time related var
    float t = time * 0.5;
    float tcos = 0.5 * (0.5 * (cos(t)+1.0)) + 0.25;

    ${this.TRANSITION}
    finalColor *= mix( distortion1 * 1.5 * (intensity * 0.2),
                       distortion2 * 1.5 * (intensity * 0.2), tcos);
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/* -------------------------------------------- */

/**
 * Ghost light animation coloration shader
 */
class GhostLightColorationShader extends AdaptiveColorationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBM(3, 1.0)}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}
    
    // Creating distortion with vUvs and fbm
    float distortion1 = fbm(vec2( 
                        fbm(vUvs * 3.0 + time * 0.50), 
                        fbm((-vUvs + vec2(1.)) * 5.0 + time * INVTHREE)));
    
    float distortion2 = fbm(vec2(
                        fbm(-vUvs * 3.0 + time * 0.50),
                        fbm((-vUvs + vec2(1.)) * 5.0 - time * INVTHREE)));
    vec2 uv = vUvs;
      
    // time related var
    float t = time * 0.5;
    float tcos = 0.5 * (0.5 * (cos(t)+1.0)) + 0.25;
    float tsin = 0.5 * (0.5 * (sin(t)+1.0)) + 0.25;
    
    // Creating distortions with cos and sin : create fluidity
    uv -= PIVOT;
    uv *= tcos * distortion1;
    uv *= tsin * distortion2;
    uv *= fbm(vec2(time + distortion1, time + distortion2));
    uv += PIVOT;

    finalColor = distortion1 * distortion1 * 
                 distortion2 * distortion2 * 
                 color * pow(1.0 - dist, dist)
                 * colorationAlpha * mix( uv.x + distortion1 * 4.5 * (intensity * 0.2),
                                          uv.y + distortion2 * 4.5 * (intensity * 0.2), tcos);
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Hexagonal dome animation coloration shader
 */
class HexaDomeColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  // rotate and scale uv
  vec2 transform(in vec2 uv, in float dist) {
    float hspherize = (1.0 - sqrt(1.0 - dist)) / dist;
    float t = -time * 0.20;
    float scale = 10.0 / (11.0 - intensity);
    float cost = cos(t);
    float sint = sin(t);

    mat2 rotmat = mat2(cost, -sint, sint, cost);
    mat2 scalemat = mat2(scale, 0.0, 0.0, scale);
    uv -= PIVOT; 
    uv *= rotmat * scalemat * hspherize;
    uv += PIVOT;
    return uv;
  }

  // Adapted classic hexa algorithm
  float hexDist(in vec2 uv) {
    vec2 p = abs(uv);
    float c = dot(p, normalize(vec2(1.0, 1.73)));
    c = max(c, p.x);
    return c;
  }

  vec4 hexUvs(in vec2 uv) {
    const vec2 r = vec2(1.0, 1.73);
    const vec2 h = r*0.5;
    
    vec2 a = mod(uv, r) - h;
    vec2 b = mod(uv - h, r) - h;
    vec2 gv = dot(a, a) < dot(b,b) ? a : b;
    
    float x = atan(gv.x, gv.y);
    float y = 0.55 - hexDist(gv);
    vec2 id = uv - gv;
    return vec4(x, y, id.x, id.y);
  }

  vec3 hexa(in vec2 uv) {
    float t = time;
    vec2 uv1 = uv + vec2(0.0, sin(uv.y) * 0.25);
    vec2 uv2 = 0.5 * uv1 + 0.5 * uv + vec2(0.55, 0);
    float a = 0.2;
    float c = 0.5;
    float s = -1.0;
    uv2 *= mat2(c, -s, s, c);

    vec3 col = color;
    float hexy = hexUvs(uv2 * 10.0).y;
    float hexa = smoothstep( 3.0 * (cos(t)) + 4.5, 12.0, hexy * 20.0) * 3.0;

    col *= mix(hexa, 1.0 - hexa, min(hexy, 1.0 - hexy));
    col += color * fract(smoothstep(1.0, 2.0, hexy * 20.0)) * 0.65;
    return col;
  }

  void main() {
    ${this.FRAGMENT_BEGIN}

    // Rotate, magnify and hemispherize the uvs
    vec2 uv = transform(vUvs, dist);
    
    // Hexaify the uv (hemisphere) and apply fade and alpha
    finalColor = hexa(uv) * pow(1.0 - dist, 0.18) * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Light dome animation coloration shader
 */
class LightDomeColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBM(2)}
  ${this.PERCEIVED_BRIGHTNESS}

  // Rotate and scale uv
  vec2 transform(in vec2 uv, in float dist) {
    float hspherize = (1.0 - sqrt(1.0 - dist)) / dist;
    float t = time * 0.02;
    mat2 rotmat = mat2(cos(t), -sin(t), sin(t), cos(t));
    mat2 scalemat = mat2(8.0 * intensity, 0.0, 0.0, 8.0 * intensity);
    uv -= PIVOT; 
    uv *= rotmat * scalemat * hspherize;
    uv += PIVOT;
    return uv;
  }
  
  vec3 ripples(in vec2 uv) {
    // creating the palette
    vec3 c1 = color * 0.550;
    vec3 c2 = color * 0.020;
    vec3 c3 = color * 0.3;
    vec3 c4 = color;
    vec3 c5 = color * 0.025;
    vec3 c6 = color * 0.200;

    vec2 p = uv + vec2(5.0);
    float q = 2.0 * fbm(p + time * 0.2);
    vec2 r = vec2(fbm(p + q + ( time  ) - p.x - p.y), fbm(p * 2.0 + ( time )));
    
    return clamp( mix( c1, c2, abs(fbm(p + r)) ) + mix( c3, c4, abs(r.x * r.x * r.x) ) - mix( c5, c6, abs(r.y * r.y)), vec3(0.0), vec3(1.0));
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    
    // to hemispherize, rotate and magnify
    vec2 uv = transform(vUvs, dist);
    finalColor = ripples(uv) * pow(1.0 - dist, 0.25) * colorationAlpha;

    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Creates a gloomy ring of pure darkness.
 */
class MagicalGloomDarknessShader extends AdaptiveDarknessShader {

  /* -------------------------------------------- */
  /*  GLSL Statics                                */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBMHQ()}
  
  vec3 colorScale(in float t) {
    return vec3(1.0 + 0.8 * t) * t;
  }
  
  vec2 radialProjection(in vec2 uv, in float s, in float i) {
    uv = vec2(0.5) - uv;
    float px = 1.0 - fract(atan(uv.y, uv.x) / TWOPI + 0.25) + s;
    float py = (length(uv) * (1.0 + i * 2.0) - i) * 2.0;
    return vec2(px, py);
  }
  
  float interference(in vec2 n) {
    float noise1 = noise(n);
    float noise2 = noise(n * 2.1) * 0.6;
    float noise3 = noise(n * 5.4) * 0.42;
    return noise1 + noise2 + noise3;
  }
  
  float illuminate(in vec2 uv) {
    float t = time;
    
    // Adjust x-coordinate based on time and y-value
    float xOffset = uv.y < 0.5 
                    ? 23.0 + t * 0.035 
                    : -11.0 + t * 0.03;
    uv.x += xOffset;
    
    // Shift y-coordinate to range [0, 0.5]
    uv.y = abs(uv.y - 0.5);
    
    // Scale x-coordinate
    uv.x *= (10.0 + 80.0 * intensity * 0.2);
    
    // Compute interferences
    float q = interference(uv - t * 0.013) * 0.5;
    vec2 r = vec2(interference(uv + q * 0.5 + t - uv.x - uv.y), interference(uv + q - t));
    
    // Compute final shade value
    float sh = (r.y + r.y) * max(0.0, uv.y) + 0.1;
    return sh * sh * sh;
  }
  
  vec3 voidHalf(in float intensity) {
    float minThreshold = 0.35;
    
    // Alter gradient
    intensity = pow(intensity, 0.75);
    
    // Compute the gradient
    vec3 color = colorScale(intensity);
    
    // Normalize the color by the sum of m2 and the color values
    color /= (1.0 + max(vec3(0), color));
    return color;
  }
    
  vec3 voidRing(in vec2 uvs) {
    vec2 uv = (uvs - 0.5) / (borderDistance * 1.06) + 0.5;
    float r = 3.6;
    float ff = 1.0 - uv.y;
    vec2 uv2 = uv;
    uv2.y = 1.0 - uv2.y;
    
    // Calculate color for upper half
    vec3 colorUpper = voidHalf(illuminate(radialProjection(uv, 1.0, r))) * ff;
    
    // Calculate color for lower half
    vec3 colorLower = voidHalf(illuminate(radialProjection(uv2, 1.9, r))) * (1.0 - ff);
    
    // Return upper and lower half combined
    return colorUpper + colorLower;
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    float lumBase = perceivedBrightness(finalColor);
    lumBase = mix(lumBase, lumBase * 0.33, darknessLevel);   
    vec3 voidRingColor = voidRing(vUvs);
    float lum = pow(perceivedBrightness(voidRingColor), 4.0);
    vec3 voidRingFinal = vec3(perceivedBrightness(voidRingColor)) * color;
    finalColor = voidRingFinal * lumBase * colorationAlpha;
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Pulse animation illumination shader
 */
class PulseIlluminationShader extends AdaptiveIlluminationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}
    float fading = pow(abs(1.0 - dist * dist), 1.01 - ratio);
    ${this.TRANSITION}
    finalColor *= fading;
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/* -------------------------------------------- */

/**
 * Pulse animation coloration shader
 */
class PulseColorationShader extends AdaptiveColorationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  float pfade(in float dist, in float pulse) {
      return 1.0 - smoothstep(pulse * 0.5, 1.0, dist);
  }
    
  void main() {
    ${this.FRAGMENT_BEGIN}
    finalColor = color * pfade(dist, pulse) * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;

  /** @inheritdoc */
  static defaultUniforms = ({...super.defaultUniforms, pulse: 0});
}

/**
 * Radial rainbow animation coloration shader
 */
class RadialRainbowColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.HSB2RGB}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}

    float intens = intensity * 0.1;
    vec2 nuv = vUvs * 2.0 - 1.0;
    vec2 puv = vec2(atan(nuv.x, nuv.y) * INVTWOPI + 0.5, length(nuv)); 
    vec3 rainbow = hsb2rgb(vec3(puv.y - time * 0.2, 1.0, 1.0));
    finalColor = mix(color, rainbow, smoothstep(0.0, 1.5 - intens, dist))
                  * (1.0 - dist * dist * dist);
    
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Revolving animation coloration shader
 */
class RevolvingColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  uniform float gradientFade;
  uniform float beamLength;
  
  ${this.PERCEIVED_BRIGHTNESS}
  ${this.PIE}
  ${this.ROTATION}

  void main() {
    ${this.FRAGMENT_BEGIN}
    vec2 ncoord = vUvs * 2.0 - 1.0;
    float angularIntensity = mix(PI, PI * 0.5, intensity * 0.1);
    ncoord *= rot(angle + time);
    float angularCorrection = pie(ncoord, angularIntensity, gradientFade, beamLength);
    finalColor = color * colorationAlpha * angularCorrection;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }
  `;

  /** @inheritdoc */
  static defaultUniforms = {
    ...super.defaultUniforms,
    angle: 0,
    gradientFade: 0.15,
    beamLength: 1
  };
}

/**
 * Roling mass illumination shader - intended primarily for darkness
 */
class RoilingDarknessShader extends AdaptiveDarknessShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBM(3)}

  void main() {
    ${this.FRAGMENT_BEGIN}
    // Creating distortion with vUvs and fbm
    float distortion1 = fbm( vec2( 
                        fbm( vUvs * 2.5 + time * 0.5),
                        fbm( (-vUvs - vec2(0.01)) * 5.0 + time * INVTHREE)));
    
    float distortion2 = fbm( vec2(
                        fbm( -vUvs * 5.0 + time * 0.5),
                        fbm( (vUvs + vec2(0.01)) * 2.5 + time * INVTHREE)));
    
    // Timed values
    float t = -time * 0.5;
    float cost = cos(t);
    float sint = sin(t);
    
    // Rotation matrix
    mat2 rotmat = mat2(cost, -sint, sint, cost);
    vec2 uv = vUvs;

    // Applying rotation before distorting
    uv -= vec2(0.5);
    uv *= rotmat;
    uv += vec2(0.5);

    // Amplify distortions
    vec2 dstpivot = vec2( sin(min(distortion1 * 0.1, distortion2 * 0.1)),
                          cos(min(distortion1 * 0.1, distortion2 * 0.1)) ) * INVTHREE
                  - vec2( cos(max(distortion1 * 0.1, distortion2 * 0.1)),
                          sin(max(distortion1 * 0.1, distortion2 * 0.1)) ) * INVTHREE ;
    vec2 apivot = PIVOT - dstpivot;
    uv -= apivot;
    uv *= 1.13 + 1.33 * (cos(sqrt(max(distortion1, distortion2)) + 1.0) * 0.5);
    uv += apivot;

    // distorted distance
    float ddist = clamp(distance(uv, PIVOT) * 2.0, 0.0, 1.0);

    // R'lyeh Ftagnh !
    float smooth = smoothstep(borderDistance, borderDistance * 1.2, ddist);
    float inSmooth = min(smooth, 1.0 - smooth) * 2.0;
    
    // Creating the spooky membrane around the bright area
    vec3 membraneColor = vec3(1.0 - inSmooth);
   
    finalColor *= (mix(color, color * 0.33, darknessLevel) * colorationAlpha);
    finalColor = mix(finalColor, 
                     vec3(0.0), 
                     1.0 - smoothstep(0.25, 0.30 + (intensity * 0.2), ddist));    
    finalColor *= membraneColor;
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Siren light animation coloration shader
 */
class SirenColorationShader extends AdaptiveColorationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  uniform float gradientFade;
  uniform float beamLength;
  
  ${this.PERCEIVED_BRIGHTNESS}
  ${this.PIE}
  ${this.ROTATION}

  void main() {
    ${this.FRAGMENT_BEGIN}
    vec2 ncoord = vUvs * 2.0 - 1.0;
    float angularIntensity = mix(PI, 0.0, intensity * 0.1);
    ncoord *= rot(time * 50.0 + angle);
    float angularCorrection = pie(ncoord, angularIntensity, clamp(gradientFade * dist, 0.05, 1.0), beamLength);
    finalColor = color * brightnessPulse * colorationAlpha * angularCorrection;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }
  `;

  /** @inheritdoc */
  static defaultUniforms = ({
    ...super.defaultUniforms,
    ratio: 0,
    brightnessPulse: 1,
    angle: 0,
    gradientFade: 0.15,
    beamLength: 1
  });
}

/* -------------------------------------------- */

/**
 * Siren light animation illumination shader
 */
class SirenIlluminationShader extends AdaptiveIlluminationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  uniform float gradientFade;
  uniform float beamLength;
  
  ${this.PERCEIVED_BRIGHTNESS}
  ${this.PIE}
  ${this.ROTATION}

  void main() {
    ${this.FRAGMENT_BEGIN}
    ${this.TRANSITION}
    vec2 ncoord = vUvs * 2.0 - 1.0;
    float angularIntensity = mix(PI, 0.0, intensity * 0.1);
    ncoord *= rot(time * 50.0 + angle);
    float angularCorrection = mix(1.0, pie(ncoord, angularIntensity, clamp(gradientFade * dist, 0.05, 1.0), beamLength), 0.5);
    finalColor *= angularCorrection;
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;

  /** @inheritdoc */
  static defaultUniforms = ({
    ...super.defaultUniforms,
    angle: 0,
    gradientFade: 0.45,
    beamLength: 1
  });
}

/**
 * A patch of smoke
 */
class SmokePatchColorationShader extends AdaptiveColorationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBMHQ(3)}
  
  vec2 transform(in vec2 uv, in float dist) {
    float t = time * 0.1;
    float cost = cos(t);
    float sint = sin(t);

    mat2 rotmat = mat2(cost, -sint, sint, cost);
    mat2 scalemat = mat2(10.0, uv.x, uv.y, 10.0);
    uv -= PIVOT;
    uv *= (rotmat * scalemat);
    uv += PIVOT;
    return uv;
  }

  float smokefading(in float dist) {
    float t = time * 0.4;
    vec2 uv = transform(vUvs, dist);
    return pow(1.0 - dist, 
      mix(fbm(uv, 1.0 + intensity * 0.4), 
        max(fbm(uv + t, 1.0),
            fbm(uv - t, 1.0)), 
          pow(dist, intensity * 0.5)));
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    finalColor = color * smokefading(dist) * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }
  `;
}

/* -------------------------------------------- */

/**
 * A patch of smoke
 */
class SmokePatchIlluminationShader extends AdaptiveIlluminationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBMHQ(3)}

  vec2 transform(in vec2 uv, in float dist) {
    float t = time * 0.1;
    float cost = cos(t);
    float sint = sin(t);

    mat2 rotmat = mat2(cost, -sint, sint, cost);
    mat2 scalemat = mat2(10.0, uv.x, uv.y, 10.0);
    uv -= PIVOT;
    uv *= (rotmat * scalemat);
    uv += PIVOT;
    return uv;
  }
  
  float smokefading(in float dist) {
    float t = time * 0.4;
    vec2 uv = transform(vUvs, dist);
    return pow(1.0 - dist,
      mix(fbm(uv, 1.0 + intensity * 0.4),
        max(fbm(uv + t, 1.0),
            fbm(uv - t, 1.0)),
        pow(dist, intensity * 0.5)));
  }

  void main() {
    ${this.FRAGMENT_BEGIN}                          
    ${this.TRANSITION}
    finalColor *= smokefading(dist);
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }
  `;
}

/**
 * A disco like star light.
 */
class StarLightColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBM(2, 1.0)}

  vec2 transform(in vec2 uv, in float dist) {
    float t = time * 0.20;
    float cost = cos(t);
    float sint = sin(t);

    mat2 rotmat = mat2(cost, -sint, sint, cost);
    uv *= rotmat;
    return uv;
  }

  float makerays(in vec2 uv, in float t) {
    vec2 uvn = normalize(uv * (uv + t)) * (5.0 + intensity);
    return max(clamp(0.5 * tan(fbm(uvn - t)), 0.0, 2.25),
               clamp(3.0 - tan(fbm(uvn + t * 2.0)), 0.0, 2.25));
  }

  float starlight(in float dist) {
    vec2 uv = (vUvs - 0.5);
    uv = transform(uv, dist);
    float rays = makerays(uv, time * 0.5);
    return pow(1.0 - dist, rays) * pow(1.0 - dist, 0.25);
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    finalColor = clamp(color * starlight(dist) * colorationAlpha, 0.0, 1.0);
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }
  `;
}

/**
 * Sunburst animation illumination shader
 */
class SunburstIlluminationShader extends AdaptiveIlluminationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  // Smooth back and forth between a and b
  float cosTime(in float a, in float b) {
    return (a - b) * ((cos(time) + 1.0) * 0.5) + b;
  }

  // Create the sunburst effect
  vec3 sunBurst(in vec3 color, in vec2 uv, in float dist) {
    // Pulse calibration
    float intensityMod = 1.0 + (intensity * 0.05);
    float lpulse = cosTime(1.3 * intensityMod, 0.85 * intensityMod);
    
    // Compute angle
    float angle = atan(uv.x, uv.y) * INVTWOPI;
    
    // Creating the beams and the inner light
    float beam = fract(angle * 16.0 + time);
    float light = lpulse * pow(abs(1.0 - dist), 0.65);
    
    // Max agregation of the central light and the two gradient edges
    float sunburst = max(light, max(beam, 1.0 - beam));
        
    // Creating the effect : applying color and color correction. ultra saturate the entire output color.
    return color * pow(sunburst, 3.0);
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    vec2 uv = (2.0 * vUvs) - 1.0;
    finalColor = switchColor(computedBrightColor, computedDimColor, dist);
    ${this.ADJUSTMENTS}
    finalColor = sunBurst(finalColor, uv, dist);
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Sunburst animation coloration shader
 */
class SunburstColorationShader extends AdaptiveColorationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  // Smooth back and forth between a and b
  float cosTime(in float a, in float b) {
    return (a - b) * ((cos(time) + 1.0) * 0.5) + b;
  }

  // Create a sun burst effect
  vec3 sunBurst(in vec2 uv, in float dist) {
    // pulse calibration
    float intensityMod = 1.0 + (intensity * 0.05);
    float lpulse = cosTime(1.1 * intensityMod, 0.85 * intensityMod);

    // compute angle
    float angle = atan(uv.x, uv.y) * INVTWOPI;
    
    // creating the beams and the inner light
    float beam = fract(angle * 16.0 + time);
    float light = lpulse * pow(abs(1.0 - dist), 0.65);
    
    // agregation of the central light and the two gradient edges to create the sunburst
    float sunburst = max(light, max(beam, 1.0 - beam));
        
    // creating the effect : applying color and color correction. saturate the entire output color.
    return color * pow(sunburst, 3.0);
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    vec2 uvs = (2.0 * vUvs) - 1.0;
    finalColor = sunBurst(uvs, dist) * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Swirling rainbow animation coloration shader
 */
class SwirlingRainbowColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.HSB2RGB}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}

    float intens = intensity * 0.1;
    vec2 nuv = vUvs * 2.0 - 1.0;
    vec2 puv = vec2(atan(nuv.x, nuv.y) * INVTWOPI + 0.5, length(nuv));
    vec3 rainbow = hsb2rgb(vec3(puv.x + puv.y - time * 0.2, 1.0, 1.0));
    finalColor = mix(color, rainbow, smoothstep(0.0, 1.5 - intens, dist))
                     * (1.0 - dist * dist * dist);
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Allow coloring of illumination
 */
class TorchIlluminationShader extends AdaptiveIlluminationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}
    ${this.TRANSITION}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/* -------------------------------------------- */

/**
 * Torch animation coloration shader
 */
class TorchColorationShader extends AdaptiveColorationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}
    finalColor = color * brightnessPulse * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }
  `;

  /** @inheritdoc */
  static defaultUniforms = ({...super.defaultUniforms, ratio: 0, brightnessPulse: 1});
}

/**
 * Vortex animation coloration shader
 */
class VortexColorationShader extends AdaptiveColorationShader {

  /** @override */
  static forceDefaultColor = true;

  /** @override */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBM(4, 1.0)}
  ${this.PERCEIVED_BRIGHTNESS}

  vec2 vortex(in vec2 uv, in float dist, in float radius, in mat2 rotmat) {
    float intens = intensity * 0.2;
    vec2 uvs = uv - PIVOT;
    uv *= rotmat;

    if ( dist < radius ) {
      float sigma = (radius - dist) / radius;
      float theta = sigma * sigma * TWOPI * intens;
      float st = sin(theta);
      float ct = cos(theta);
      uvs = vec2(dot(uvs, vec2(ct, -st)), dot(uvs, vec2(st, ct)));
    }
    uvs += PIVOT;
    return uvs;
  }

  vec3 spice(in vec2 iuv, in mat2 rotmat) {

    // constructing the palette
    vec3 c1 = color * 0.55;
    vec3 c2 = color * 0.95;
    vec3 c3 = color * 0.45;
    vec3 c4 = color * 0.75;
    vec3 c5 = vec3(0.20);
    vec3 c6 = color * 1.2;

    // creating the deformation
    vec2 uv = iuv;
    uv -= PIVOT;
    uv *= rotmat;
    vec2 p = uv.xy * 6.0;
    uv += PIVOT;

    // time motion fbm and palette mixing
    float q = fbm(p + time);
    vec2 r = vec2(fbm(p + q + time * 0.9 - p.x - p.y), 
                  fbm(p + q + time * 0.6));
    vec3 c = mix(c1, 
                 c2, 
                 fbm(p + r)) + mix(c3, c4, r.x) 
                             - mix(c5, c6, r.y);
    // returning the color
    return c;
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    
    // Timed values
    float t = time * 0.5;
    float cost = cos(t);
    float sint = sin(t);

    // Rotation matrix
    mat2 vortexRotMat = mat2(cost, -sint, sint, cost);
    mat2 spiceRotMat = mat2(cost * 2.0, -sint * 2.0, sint * 2.0, cost * 2.0);

    // Creating vortex
    vec2 vuv = vortex(vUvs, dist, 1.0, vortexRotMat);

    // Applying spice
    finalColor = spice(vuv, spiceRotMat) * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/* -------------------------------------------- */

/**
 * Vortex animation coloration shader
 */
class VortexIlluminationShader extends AdaptiveIlluminationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PRNG}
  ${this.NOISE}
  ${this.FBM(4, 1.0)}
  ${this.PERCEIVED_BRIGHTNESS}

  vec2 vortex(in vec2 uv, in float dist, in float radius, in float angle, in mat2 rotmat) {
    vec2 uvs = uv - PIVOT;
    uv *= rotmat;

    if ( dist < radius ) {
      float sigma = (radius - dist) / radius;
      float theta = sigma * sigma * angle;
      float st = sin(theta);
      float ct = cos(theta);
      uvs = vec2(dot(uvs, vec2(ct, -st)), dot(uvs, vec2(st, ct)));
    }
    uvs += PIVOT;
    return uvs;
  }

  vec3 spice(in vec2 iuv, in mat2 rotmat) {
    // constructing the palette
    vec3 c1 = vec3(0.20);
    vec3 c2 = vec3(0.80);
    vec3 c3 = vec3(0.15);
    vec3 c4 = vec3(0.85);
    vec3 c5 = c3;
    vec3 c6 = vec3(0.9);

    // creating the deformation
    vec2 uv = iuv;
    uv -= PIVOT;
    uv *= rotmat;
    vec2 p = uv.xy * 6.0;
    uv += PIVOT;

    // time motion fbm and palette mixing
    float q = fbm(p + time);
    vec2 r = vec2(fbm(p + q + time * 0.9 - p.x - p.y), fbm(p + q + time * 0.6));

    // Mix the final color
    return mix(c1, c2, fbm(p + r)) + mix(c3, c4, r.x) - mix(c5, c6, r.y);
  }

  vec3 convertToDarknessColors(in vec3 col, in float dist) {
    float intens = intensity * 0.20;
    float lum = (col.r * 2.0 + col.g * 3.0 + col.b) * 0.5 * INVTHREE;
    float colorMod = smoothstep(ratio * 0.99, ratio * 1.01, dist);
    return mix(computedDimColor, computedBrightColor * colorMod, 1.0 - smoothstep( 0.80, 1.00, lum)) *
                smoothstep( 0.25 * intens, 0.85 * intens, lum);
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    ${this.TRANSITION}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Wave animation illumination shader
 */
class WaveIlluminationShader extends AdaptiveIlluminationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  float wave(in float dist) {
    float sinWave = 0.5 * (sin(-time * 6.0 + dist * 10.0 * intensity) + 1.0);
    return 0.3 * sinWave + 0.8;
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    ${this.TRANSITION}
    finalColor *= wave(dist);
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/* -------------------------------------------- */

/**
 * Wave animation coloration shader
 */
class WaveColorationShader extends AdaptiveColorationShader {
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  float wave(in float dist) {
    float sinWave = 0.5 * (sin(-time * 6.0 + dist * 10.0 * intensity) + 1.0);
    return 0.55 * sinWave + 0.8;
  }

  void main() {
    ${this.FRAGMENT_BEGIN}
    finalColor = color * wave(dist) * colorationAlpha;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;
}

/**
 * Shader specialized in light amplification
 */
class AmplificationBackgroundVisionShader extends BackgroundVisionShader {

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.PERCEIVED_BRIGHTNESS}

  void main() {
    ${this.FRAGMENT_BEGIN}
    float lum = perceivedBrightness(baseColor.rgb);
    vec3 vision = vec3(smoothstep(0.0, 1.0, lum * 1.5)) * colorTint;
    finalColor = vision + (vision * (lum + brightness) * 0.1) + (baseColor.rgb * (1.0 - computedDarknessLevel) * 0.125);
    ${this.ADJUSTMENTS}
    ${this.BACKGROUND_TECHNIQUES}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;

  /** @inheritdoc */
  static defaultUniforms = ({...super.defaultUniforms, colorTint: [0.38, 0.8, 0.38], brightness: 0.5});

  /** @inheritdoc */
  get isRequired() {
    return true;
  }
}

/**
 * Shader specialized in wave like senses (tremorsenses)
 */
class WaveBackgroundVisionShader extends BackgroundVisionShader {

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.WAVE()}
  ${this.PERCEIVED_BRIGHTNESS}
  
  void main() {
    ${this.FRAGMENT_BEGIN}    
    // Normalize vUvs and compute base time
    vec2 uvs = (2.0 * vUvs) - 1.0;
    float t = time * -8.0;
    
    // Rotate uvs
    float sinX = sin(t * 0.02);
    float cosX = cos(t * 0.02);
    mat2 rotationMatrix = mat2( cosX, -sinX, sinX, cosX);
    vec2 ruv = ((vUvs - 0.5) * rotationMatrix) + 0.5;
    
    // Produce 4 arms smoothed to the edges
    float angle = atan(ruv.x * 2.0 - 1.0, ruv.y * 2.0 - 1.0) * INVTWOPI;
    float beam = fract(angle * 4.0);
    beam = smoothstep(0.3, 1.0, max(beam, 1.0 - beam));
    
    // Construct final color
    vec3 grey = vec3(perceivedBrightness(baseColor.rgb));
    finalColor = mix(baseColor.rgb, grey * 0.5, sqrt(beam)) * mix(vec3(1.0), colorTint, 0.3);
    ${this.ADJUSTMENTS}
    ${this.BACKGROUND_TECHNIQUES}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;

  /** @inheritdoc */
  static defaultUniforms = ({...super.defaultUniforms, colorTint: [0.8, 0.1, 0.8]});

  /** @inheritdoc */
  get isRequired() {
    return true;
  }
}

/* -------------------------------------------- */

/**
 * The wave vision shader, used to create waves emanations (ex: tremorsense)
 */
class WaveColorationVisionShader extends ColorationVisionShader {

  /** @inheritdoc */
  static fragmentShader = `
  ${this.SHADER_HEADER}
  ${this.WAVE()}
  ${this.PERCEIVED_BRIGHTNESS}
    
  void main() {
    ${this.FRAGMENT_BEGIN}
    // Normalize vUvs and compute base time
    vec2 uvs = (2.0 * vUvs) - 1.0;
    float t = time * -8.0;
    
    // Rotate uvs
    float sinX = sin(t * 0.02);
    float cosX = cos(t * 0.02);
    mat2 rotationMatrix = mat2( cosX, -sinX, sinX, cosX);
    vec2 ruv = ((vUvs - 0.5) * rotationMatrix) + 0.5;
    
    // Prepare distance from 4 corners
    float dst[4];
    dst[0] = distance(vec2(0.0), ruv);
    dst[1] = distance(vec2(1.0), ruv);
    dst[2] = distance(vec2(1.0,0.0), ruv);
    dst[3] = distance(vec2(0.0,1.0), ruv);
    
    // Produce 4 arms smoothed to the edges
    float angle = atan(ruv.x * 2.0 - 1.0, ruv.y * 2.0 - 1.0) * INVTWOPI;
    float beam = fract(angle * 4.0);
    beam = smoothstep(0.3, 1.0, max(beam, 1.0 - beam));
    
    // Computing the 4 corner waves
    float multiWaves = 0.0;
    for ( int i = 0; i <= 3 ; i++) {
      multiWaves += smoothstep(0.6, 1.0, max(multiWaves, wcos(-10.0, 1.30 - dst[i], dst[i] * 120.0, t)));
    }
    // Computing the central wave
    multiWaves += smoothstep(0.6, 1.0, max(multiWaves, wcos(-10.0, 1.35 - dist, dist * 120.0, -t)));
        
    // Construct final color
    finalColor = vec3(mix(multiWaves, 0.0, sqrt(beam))) * colorEffect;
    ${this.COLORATION_TECHNIQUES}
    ${this.ADJUSTMENTS}
    ${this.FALLOFF}
    ${this.FRAGMENT_END}
  }`;

  /** @inheritdoc */
  static defaultUniforms = ({...super.defaultUniforms, colorEffect: [0.8, 0.1, 0.8]});

  /** @inheritdoc */
  get isRequired() {
    return true;
  }
}

/**
 * Determine the center of the circle.
 * Trivial, but used to match center method for other shapes.
 * @type {PIXI.Point}
 */
Object.defineProperty(PIXI.Circle.prototype, "center", { get: function() {
  return new PIXI.Point(this.x, this.y);
}});

/* -------------------------------------------- */

/**
 * Determine if a point is on or nearly on this circle.
 * @param {Point} point       Point to test
 * @param {number} epsilon    Tolerated margin of error
 * @returns {boolean}         Is the point on the circle within the allowed tolerance?
 */
PIXI.Circle.prototype.pointIsOn = function(point, epsilon = 1e-08) {
  const dist2 = Math.pow(point.x - this.x, 2) + Math.pow(point.y - this.y, 2);
  const r2 = Math.pow(this.radius, 2);
  return dist2.almostEqual(r2, epsilon);
};

/* -------------------------------------------- */

/**
 * Get all intersection points on this circle for a segment A|B
 * Intersections are sorted from A to B.
 * @param {Point} a             The first endpoint on segment A|B
 * @param {Point} b             The second endpoint on segment A|B
 * @returns {Point[]}           Points where the segment A|B intersects the circle
 */
PIXI.Circle.prototype.segmentIntersections = function(a, b) {
  const ixs = foundry.utils.lineCircleIntersection(a, b, this, this.radius);
  return ixs.intersections;
};

/* -------------------------------------------- */

/**
 * Calculate an x,y point on this circle's circumference given an angle
 * 0: due east
 * π / 2: due south
 * π or -π: due west
 * -π/2: due north
 * @param {number} angle      Angle of the point, in radians
 * @returns {Point}           The point on the circle at the given angle
 */
PIXI.Circle.prototype.pointAtAngle = function(angle) {
  return {
    x: this.x + (this.radius * Math.cos(angle)),
    y: this.y + (this.radius * Math.sin(angle))
  };
};

/* -------------------------------------------- */

/**
 * Get all the points for a polygon approximation of this circle between two points.
 * The two points can be anywhere in 2d space. The intersection of this circle with the line from this circle center
 * to the point will be used as the start or end point, respectively.
 * This is used to draw the portion of the circle (the arc) between two intersection points on this circle.
 * @param {Point} a             Point in 2d space representing the start point
 * @param {Point} b             Point in 2d space representing the end point
 * @param {object} [options]    Options passed on to the pointsForArc method
 * @returns { Point[]}          An array of points arranged clockwise from start to end
 */
PIXI.Circle.prototype.pointsBetween = function(a, b, options) {
  const fromAngle = Math.atan2(a.y - this.y, a.x - this.x);
  const toAngle = Math.atan2(b.y - this.y, b.x - this.x);
  return this.pointsForArc(fromAngle, toAngle, { includeEndpoints: false, ...options });
};

/* -------------------------------------------- */

/**
 * Get the points that would approximate a circular arc along this circle, given a starting and ending angle.
 * Points returned are clockwise. If from and to are the same, a full circle will be returned.
 * @param {number} fromAngle     Starting angle, in radians. π is due north, π/2 is due east
 * @param {number} toAngle       Ending angle, in radians
 * @param {object} [options]     Options which affect how the circle is converted
 * @param {number} [options.density]           The number of points which defines the density of approximation
 * @param {boolean} [options.includeEndpoints]  Whether to include points at the circle where the arc starts and ends
 * @returns {Point[]}             An array of points along the requested arc
 */
PIXI.Circle.prototype.pointsForArc = function(fromAngle, toAngle, {density, includeEndpoints=true} = {}) {
  const pi2 = 2 * Math.PI;
  density ??= this.constructor.approximateVertexDensity(this.radius);
  const points = [];
  const delta = pi2 / density;
  if ( includeEndpoints ) points.push(this.pointAtAngle(fromAngle));

  // Determine number of points to add
  let dAngle = toAngle - fromAngle;
  while ( dAngle <= 0 ) dAngle += pi2; // Angles may not be normalized, so normalize total.
  const nPoints = Math.round(dAngle / delta);

  // Construct padding rays (clockwise)
  for ( let i = 1; i < nPoints; i++ ) points.push(this.pointAtAngle(fromAngle + (i * delta)));
  if ( includeEndpoints ) points.push(this.pointAtAngle(toAngle));
  return points;
};

/* -------------------------------------------- */

/**
 * Approximate this PIXI.Circle as a PIXI.Polygon
 * @param {object} [options]      Options forwarded on to the pointsForArc method
 * @returns {PIXI.Polygon}        The Circle expressed as a PIXI.Polygon
 */
PIXI.Circle.prototype.toPolygon = function(options) {
  const points = this.pointsForArc(0, 0, options);
  points.pop(); // Drop the repeated endpoint
  return new PIXI.Polygon(points);
};

/* -------------------------------------------- */

/**
 * The recommended vertex density for the regular polygon approximation of a circle of a given radius.
 * Small radius circles have fewer vertices. The returned value will be rounded up to the nearest integer.
 * See the formula described at:
 * https://math.stackexchange.com/questions/4132060/compute-number-of-regular-polgy-sides-to-approximate-circle-to-defined-precision
 * @param {number} radius     Circle radius
 * @param {number} [epsilon]  The maximum tolerable distance between an approximated line segment and the true radius.
 *                            A larger epsilon results in fewer points for a given radius.
 * @returns {number}          The number of points for the approximated polygon
 */
PIXI.Circle.approximateVertexDensity = function(radius, epsilon=1) {
  return Math.ceil(Math.PI / Math.sqrt(2 * (epsilon / radius)));
};

/* -------------------------------------------- */

/**
 * Intersect this PIXI.Circle with a PIXI.Polygon.
 * @param {PIXI.Polygon} polygon      A PIXI.Polygon
 * @param {object} [options]          Options which configure how the intersection is computed
 * @param {number} [options.density]              The number of points which defines the density of approximation
 * @param {number} [options.clipType]             The clipper clip type
 * @param {string} [options.weilerAtherton=true]  Use the Weiler-Atherton algorithm. Otherwise, use Clipper.
 * @returns {PIXI.Polygon}            The intersected polygon
 */
PIXI.Circle.prototype.intersectPolygon = function(polygon, {density, clipType, weilerAtherton=true, ...options}={}) {
  if ( !this.radius ) return new PIXI.Polygon([]);
  clipType ??= ClipperLib.ClipType.ctIntersection;

  // Use Weiler-Atherton for efficient intersection or union
  if ( weilerAtherton && polygon.isPositive ) {
    const res = WeilerAthertonClipper.combine(polygon, this, {clipType, density, ...options});
    if ( !res.length ) return new PIXI.Polygon([]);
    return res[0];
  }

  // Otherwise, use Clipper polygon intersection
  const approx = this.toPolygon({density});
  return polygon.intersectPolygon(approx, options);
};

/* -------------------------------------------- */

/**
 * Intersect this PIXI.Circle with an array of ClipperPoints.
 * Convert the circle to a Polygon approximation and use intersectPolygon.
 * In the future we may replace this with more specialized logic which uses the line-circle intersection formula.
 * @param {ClipperPoint[]} clipperPoints  Array of ClipperPoints generated by PIXI.Polygon.toClipperPoints()
 * @param {object} [options]              Options which configure how the intersection is computed
 * @param {number} [options.density]      The number of points which defines the density of approximation
 * @returns {PIXI.Polygon}                The intersected polygon
 */
PIXI.Circle.prototype.intersectClipper = function(clipperPoints, {density, ...options}={}) {
  if ( !this.radius ) return [];
  const approx = this.toPolygon({density});
  return approx.intersectClipper(clipperPoints, options);
};


/**
 * Draws a path.
 * @param {number[]|PIXI.IPointData[]|PIXI.Polygon|...number|...PIXI.IPointData} path    The polygon or points.
 * @returns {this}    This Graphics instance.
 */
PIXI.Graphics.prototype.drawPath = function(...path) {
  let closeStroke = false;
  let polygon = path[0];
  let points;
  if ( polygon.points ) {
    closeStroke = polygon.closeStroke;
    points = polygon.points;
  } else if ( Array.isArray(path[0]) ) {
    points = path[0];
  } else {
    points = path;
  }
  polygon = new PIXI.Polygon(points);
  polygon.closeStroke = closeStroke;
  return this.drawShape(polygon);
};
PIXI.LegacyGraphics.prototype.drawPath = PIXI.Graphics.prototype.drawPath;
PIXI.smooth.SmoothGraphics.prototype.drawPath = PIXI.Graphics.prototype.drawPath;

/* -------------------------------------------- */

/**
 * Draws a smoothed polygon.
 * @param {number[]|PIXI.IPointData[]|PIXI.Polygon|...number|...PIXI.IPointData} path    The polygon or points.
 * @param {number} [smoothing=0]    The smoothness in the range [0, 1]. 0: no smoothing; 1: maximum smoothing.
 * @returns {this}                  This Graphics instance.
 */
PIXI.Graphics.prototype.drawSmoothedPolygon = function(...path) {
  let closeStroke = true;
  let polygon = path[0];
  let points;
  let factor;
  if ( polygon.points ) {
    closeStroke = polygon.closeStroke;
    points = polygon.points;
    factor = path[1];
  } else if ( Array.isArray(path[0]) ) {
    points = path[0];
    factor = path[1];
  } else if ( typeof path[0] === "number" ) {
    points = path;
    factor = path.length % 2 ? path.at(-1) : 0;
  } else {
    const n = path.length - (typeof path.at(-1) !== "object" ? 1 : 0);
    points = [];
    for ( let i = 0; i < n; i++ ) points.push(path[i].x, path[i].y);
    factor = path.at(n);
  }
  factor ??= 0;
  if ( (points.length < 6) || (factor <= 0) ) {
    polygon = new PIXI.Polygon(points.slice(0, points.length - (points.length % 2)));
    polygon.closeStroke = closeStroke;
    return this.drawShape(polygon);
  }
  const dedupedPoints = [points[0], points[1]];
  for ( let i = 2; i < points.length - 1; i += 2 ) {
    const x = points[i];
    const y = points[i + 1];
    if ( (x === points[i - 2]) && (y === points[i - 1]) ) continue;
    dedupedPoints.push(x, y);
  }
  points = dedupedPoints;
  if ( closeStroke && (points[0] === points.at(-2)) && (points[1] === points.at(-1)) ) points.length -= 2;
  if ( points.length < 6 ) {
    polygon = new PIXI.Polygon(points);
    polygon.closeStroke = closeStroke;
    return this.drawShape(polygon);
  }
  const getBezierControlPoints = (fromX, fromY, toX, toY, nextX, nextY) => {
    const vectorX = nextX - fromX;
    const vectorY = nextY - fromY;
    const preDistance = Math.hypot(toX - fromX, toY - fromY);
    const postDistance = Math.hypot(nextX - toX, nextY - toY);
    const totalDistance = preDistance + postDistance;
    const cp0d = 0.5 * factor * (preDistance / totalDistance);
    const cp1d = 0.5 * factor * (postDistance / totalDistance);
    return [
      toX - (vectorX * cp0d),
      toY - (vectorY * cp0d),
      toX + (vectorX * cp1d),
      toY + (vectorY * cp1d)
    ];
  };
  let [fromX, fromY, toX, toY] = points;
  let [cpX, cpY, cpXNext, cpYNext] = getBezierControlPoints(points.at(-2), points.at(-1), fromX, fromY, toX, toY);
  this.moveTo(fromX, fromY);
  for ( let i = 2, n = points.length + (closeStroke ? 2 : 0); i < n; i += 2 ) {
    const nextX = points[(i + 2) % points.length];
    const nextY = points[(i + 3) % points.length];
    cpX = cpXNext;
    cpY = cpYNext;
    let cpX2;
    let cpY2;
    [cpX2, cpY2, cpXNext, cpYNext] = getBezierControlPoints(fromX, fromY, toX, toY, nextX, nextY);
    if ( !closeStroke && (i === 2) ) this.quadraticCurveTo(cpX2, cpY2, toX, toY);
    else if ( !closeStroke && (i === points.length - 2) ) this.quadraticCurveTo(cpX, cpY, toX, toY);
    else this.bezierCurveTo(cpX, cpY, cpX2, cpY2, toX, toY);
    fromX = toX;
    fromY = toY;
    toX = nextX;
    toY = nextY;
  }
  if ( closeStroke ) this.closePath();
  this.finishPoly();
  return this;
};
PIXI.LegacyGraphics.prototype.drawSmoothedPolygon = PIXI.Graphics.prototype.drawSmoothedPolygon;
PIXI.smooth.SmoothGraphics.prototype.drawSmoothedPolygon = PIXI.Graphics.prototype.drawSmoothedPolygon;

/* -------------------------------------------- */

/**
 * Draws a smoothed path.
 * @param {number[]|PIXI.IPointData[]|PIXI.Polygon|...number|...PIXI.IPointData} path    The polygon or points.
 * @param {number} [smoothing=0]    The smoothness in the range [0, 1]. 0: no smoothing; 1: maximum smoothing.
 * @returns {this}                  This Graphics instance.
 */
PIXI.Graphics.prototype.drawSmoothedPath = function(...path) {
  let closeStroke = false;
  let polygon = path[0];
  let points;
  let factor;
  if ( polygon.points ) {
    closeStroke = polygon.closeStroke;
    points = polygon.points;
    factor = path[1];
  } else if ( Array.isArray(path[0]) ) {
    points = path[0];
    factor = path[1];
  } else if ( typeof path[0] === "number" ) {
    points = path;
    factor = path.length % 2 ? path.at(-1) : 0;
  } else {
    const n = path.length - (typeof path.at(-1) !== "object" ? 1 : 0);
    points = [];
    for ( let i = 0; i < n; i++ ) points.push(path[i].x, path[i].y);
    factor = path.at(n);
  }
  polygon = new PIXI.Polygon(points);
  polygon.closeStroke = closeStroke;
  return this.drawSmoothedPolygon(polygon, factor);
};
PIXI.LegacyGraphics.prototype.drawSmoothedPath = PIXI.Graphics.prototype.drawSmoothedPath;
PIXI.smooth.SmoothGraphics.prototype.drawSmoothedPath = PIXI.Graphics.prototype.drawSmoothedPath;

/**
 * A custom Transform class allowing to observe changes with a callback.
 * @extends PIXI.Transform
 *
 * @param {Function} callback          The callback called to observe changes.
 * @param {Object} scope               The scope of the callback.
 */
class ObservableTransform extends PIXI.Transform {
  constructor(callback, scope) {
    super();
    if ( !(callback instanceof Function) ) {
      throw new Error("The callback bound to an ObservableTransform class must be a valid function.")
    }
    if ( !(scope instanceof Object) ) {
      throw new Error("The scope bound to an ObservableTransform class must be a valid object/class.")
    }
    this.scope = scope;
    this.cb = callback;
  }

  /**
   * The callback which is observing the changes.
   * @type {Function}
   */
  cb;

  /**
   * The scope of the callback.
   * @type {Object}
   */
  scope;

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  onChange() {
    super.onChange();
    this.cb.call(this.scope);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  updateSkew() {
    super.updateSkew();
    this.cb.call(this.scope);
  }
}

/**
 * Test whether the polygon is has a positive signed area.
 * Using a y-down axis orientation, this means that the polygon is "clockwise".
 * @type {boolean}
 */
Object.defineProperties(PIXI.Polygon.prototype, {
  isPositive: {
    get: function() {
      if ( this._isPositive !== undefined ) return this._isPositive;
      if ( this.points.length < 6 ) return undefined;
      return this._isPositive = this.signedArea() > 0;
    }
  },
  _isPositive: {value: undefined, writable: true, enumerable: false}
});

/* -------------------------------------------- */

/**
 * Clear the cached signed orientation.
 */
PIXI.Polygon.prototype.clearCache = function() {
  this._isPositive = undefined;
};

/* -------------------------------------------- */

/**
 * Compute the signed area of polygon using an approach similar to ClipperLib.Clipper.Area.
 * The math behind this is based on the Shoelace formula. https://en.wikipedia.org/wiki/Shoelace_formula.
 * The area is positive if the orientation of the polygon is positive.
 * @returns {number}              The signed area of the polygon
 */
PIXI.Polygon.prototype.signedArea = function() {
  const points = this.points;
  const ln = points.length;
  if ( ln < 6 ) return 0;

  // Compute area
  let area = 0;
  let x1 = points[ln - 2];
  let y1 = points[ln - 1];
  for ( let i = 0; i < ln; i += 2 ) {
    const x2 = points[i];
    const y2 = points[i + 1];
    area += (x2 - x1) * (y2 + y1);
    x1 = x2;
    y1 = y2;
  }

  // Negate the area because in Foundry canvas, y-axis is reversed
  // See https://sourceforge.net/p/jsclipper/wiki/documentation/#clipperlibclipperorientation
  // The 1/2 comes from the Shoelace formula
  return area * -0.5;
};

/* -------------------------------------------- */

/**
 * Reverse the order of the polygon points in-place, replacing the points array into the polygon.
 * Note: references to the old points array will not be affected.
 * @returns {PIXI.Polygon}      This polygon with its orientation reversed
 */
PIXI.Polygon.prototype.reverseOrientation = function() {
  const reversed_pts = [];
  const pts = this.points;
  const ln = pts.length - 2;
  for ( let i = ln; i >= 0; i -= 2 ) reversed_pts.push(pts[i], pts[i + 1]);
  this.points = reversed_pts;
  if ( this._isPositive !== undefined ) this._isPositive = !this._isPositive;
  return this;
};

/* -------------------------------------------- */

/**
 * Add a de-duplicated point to the Polygon.
 * @param {Point} point         The point to add to the Polygon
 * @returns {PIXI.Polygon}      A reference to the polygon for method chaining
 */
PIXI.Polygon.prototype.addPoint = function({x, y}={}) {
  const l = this.points.length;
  if ( (x === this.points[l-2]) && (y === this.points[l-1]) ) return this;
  this.points.push(x, y);
  this.clearCache();
  return this;
};

/* -------------------------------------------- */

/**
 * Return the bounding box for a PIXI.Polygon.
 * The bounding rectangle is normalized such that the width and height are non-negative.
 * @returns {PIXI.Rectangle}    The bounding PIXI.Rectangle
 */
PIXI.Polygon.prototype.getBounds = function() {
  if ( this.points.length < 2 ) return new PIXI.Rectangle(0, 0, 0, 0);
  let maxX; let maxY;
  let minX = maxX = this.points[0];
  let minY = maxY = this.points[1];
  for ( let i=3; i<this.points.length; i+=2 ) {
    const x = this.points[i-1];
    const y = this.points[i];
    if ( x < minX ) minX = x;
    else if ( x > maxX ) maxX = x;
    if ( y < minY ) minY = y;
    else if ( y > maxY ) maxY = y;
  }
  return new PIXI.Rectangle(minX, minY, maxX - minX, maxY - minY);
};

/* -------------------------------------------- */

/**
 * @typedef {Object} ClipperPoint
 * @property {number} X
 * @property {number} Y
 */

/**
 * Construct a PIXI.Polygon instance from an array of clipper points [{X,Y}, ...].
 * @param {ClipperPoint[]} points                 An array of points returned by clipper
 * @param {object} [options]                      Options which affect how canvas points are generated
 * @param {number} [options.scalingFactor=1]        A scaling factor used to preserve floating point precision
 * @returns {PIXI.Polygon}                        The resulting PIXI.Polygon
 */
PIXI.Polygon.fromClipperPoints = function(points, {scalingFactor=1}={}) {
  const polygonPoints = [];
  for ( const point of points ) {
    polygonPoints.push(point.X / scalingFactor, point.Y / scalingFactor);
  }
  return new PIXI.Polygon(polygonPoints);
};

/* -------------------------------------------- */

/**
 * Convert a PIXI.Polygon into an array of clipper points [{X,Y}, ...].
 * Note that clipper points must be rounded to integers.
 * In order to preserve some amount of floating point precision, an optional scaling factor may be provided.
 * @param {object} [options]                  Options which affect how clipper points are generated
 * @param {number} [options.scalingFactor=1]    A scaling factor used to preserve floating point precision
 * @returns {ClipperPoint[]}                  An array of points to be used by clipper
 */
PIXI.Polygon.prototype.toClipperPoints = function({scalingFactor=1}={}) {
  const points = [];
  for ( let i = 1; i < this.points.length; i += 2 ) {
    points.push({
      X: Math.round(this.points[i-1] * scalingFactor),
      Y: Math.round(this.points[i] * scalingFactor)
    });
  }
  return points;
};

/* -------------------------------------------- */

/**
 * Determine whether the PIXI.Polygon is closed, defined by having the same starting and ending point.
 * @type {boolean}
 */
Object.defineProperty(PIXI.Polygon.prototype, "isClosed", {
  get: function() {
    const ln = this.points.length;
    if ( ln < 4 ) return false;
    return (this.points[0] === this.points[ln-2]) && (this.points[1] === this.points[ln-1]);
  },
  enumerable: false
});

/* -------------------------------------------- */
/*  Intersection Methods                        */
/* -------------------------------------------- */

/**
 * Intersect this PIXI.Polygon with another PIXI.Polygon using the clipper library.
 * @param {PIXI.Polygon} other        Another PIXI.Polygon
 * @param {object} [options]          Options which configure how the intersection is computed
 * @param {number} [options.clipType]       The clipper clip type
 * @param {number} [options.scalingFactor]  A scaling factor passed to Polygon#toClipperPoints to preserve precision
 * @returns {PIXI.Polygon}       The intersected polygon
 */
PIXI.Polygon.prototype.intersectPolygon = function(other, {clipType, scalingFactor}={}) {
  const otherPts = other.toClipperPoints({scalingFactor});
  const solution = this.intersectClipper(otherPts, {clipType, scalingFactor});
  return PIXI.Polygon.fromClipperPoints(solution.length ? solution[0] : [], {scalingFactor});
};

/* -------------------------------------------- */

/**
 * Intersect this PIXI.Polygon with an array of ClipperPoints.
 * @param {ClipperPoint[]} clipperPoints    Array of clipper points generated by PIXI.Polygon.toClipperPoints()
 * @param {object} [options]                Options which configure how the intersection is computed
 * @param {number} [options.clipType]         The clipper clip type
 * @param {number} [options.scalingFactor]    A scaling factor passed to Polygon#toClipperPoints to preserve precision
 * @returns {ClipperPoint[]}                The resulting ClipperPaths
 */
PIXI.Polygon.prototype.intersectClipper = function(clipperPoints, {clipType, scalingFactor} = {}) {
  clipType ??= ClipperLib.ClipType.ctIntersection;
  const c = new ClipperLib.Clipper();
  c.AddPath(this.toClipperPoints({scalingFactor}), ClipperLib.PolyType.ptSubject, true);
  c.AddPath(clipperPoints, ClipperLib.PolyType.ptClip, true);
  const solution = new ClipperLib.Paths();
  c.Execute(clipType, solution);
  return solution;
};

/* -------------------------------------------- */

/**
 * Intersect this PIXI.Polygon with a PIXI.Circle.
 * For now, convert the circle to a Polygon approximation and use intersectPolygon.
 * In the future we may replace this with more specialized logic which uses the line-circle intersection formula.
 * @param {PIXI.Circle} circle        A PIXI.Circle
 * @param {object} [options]          Options which configure how the intersection is computed
 * @param {number} [options.density]    The number of points which defines the density of approximation
 * @returns {PIXI.Polygon}            The intersected polygon
 */
PIXI.Polygon.prototype.intersectCircle = function(circle, options) {
  return circle.intersectPolygon(this, options);
};

/* -------------------------------------------- */

/**
 * Intersect this PIXI.Polygon with a PIXI.Rectangle.
 * For now, convert the rectangle to a Polygon and use intersectPolygon.
 * In the future we may replace this with more specialized logic which uses the line-line intersection formula.
 * @param {PIXI.Rectangle} rect       A PIXI.Rectangle
 * @param {object} [options]          Options which configure how the intersection is computed
 * @returns {PIXI.Polygon}            The intersected polygon
 */
PIXI.Polygon.prototype.intersectRectangle = function(rect, options) {
  return rect.intersectPolygon(this, options);
};

/**
 * Bit code labels splitting a rectangle into zones, based on the Cohen-Sutherland algorithm.
 * See https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm
 *          left    central   right
 * top      1001    1000      1010
 * central  0001    0000      0010
 * bottom   0101    0100      0110
 * @enum {number}
 */
PIXI.Rectangle.CS_ZONES = {
  INSIDE: 0x0000,
  LEFT: 0x0001,
  RIGHT: 0x0010,
  TOP: 0x1000,
  BOTTOM: 0x0100,
  TOPLEFT: 0x1001,
  TOPRIGHT: 0x1010,
  BOTTOMRIGHT: 0x0110,
  BOTTOMLEFT: 0x0101
};

/* -------------------------------------------- */

/**
 * Calculate center of this rectangle.
 * @type {Point}
 */
Object.defineProperty(PIXI.Rectangle.prototype, "center", { get: function() {
  return { x: this.x + (this.width * 0.5), y: this.y + (this.height * 0.5) };
}});

/* -------------------------------------------- */

/**
 * Return the bounding box for a PIXI.Rectangle.
 * The bounding rectangle is normalized such that the width and height are non-negative.
 * @returns {PIXI.Rectangle}
 */
PIXI.Rectangle.prototype.getBounds = function() {
  let {x, y, width, height} = this;
  x = width > 0 ? x : x + width;
  y = height > 0 ? y : y + height;
  return new PIXI.Rectangle(x, y, Math.abs(width), Math.abs(height));
};

/* -------------------------------------------- */

/**
 * Determine if a point is on or nearly on this rectangle.
 * @param {Point} p           Point to test
 * @returns {boolean}         Is the point on the rectangle boundary?
 */
PIXI.Rectangle.prototype.pointIsOn = function(p) {
  const CSZ = PIXI.Rectangle.CS_ZONES;
  return this._getZone(p) === CSZ.INSIDE && this._getEdgeZone(p) !== CSZ.INSIDE;
};

/* -------------------------------------------- */

/**
 * Calculate the rectangle Zone for a given point located around, on, or in the rectangle.
 * See https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm
 * This differs from _getZone in how points on the edge are treated: they are not considered inside.
 * @param {Point} point                   A point to test for location relative to the rectangle
 * @returns {PIXI.Rectangle.CS_ZONES}     Which edge zone does the point belong to?
 */
PIXI.Rectangle.prototype._getEdgeZone = function(point) {
  const CSZ = PIXI.Rectangle.CS_ZONES;
  let code = CSZ.INSIDE;
  if ( point.x < this.x || point.x.almostEqual(this.x) ) code |= CSZ.LEFT;
  else if ( point.x > this.right || point.x.almostEqual(this.right) ) code |= CSZ.RIGHT;
  if ( point.y < this.y || point.y.almostEqual(this.y) ) code |= CSZ.TOP;
  else if ( point.y > this.bottom || point.y.almostEqual(this.bottom) ) code |= CSZ.BOTTOM;
  return code;
};

/* -------------------------------------------- */

/**
 * Get all the points (corners) for a polygon approximation of a rectangle between two points on the rectangle.
 * The two points can be anywhere in 2d space on or outside the rectangle.
 * The starting and ending side are based on the zone of the corresponding a and b points.
 * (See PIXI.Rectangle.CS_ZONES.)
 * This is the rectangular version of PIXI.Circle.prototype.pointsBetween, and is similarly used
 * to draw the portion of the shape between two intersection points on that shape.
 * @param { Point } a   A point on or outside the rectangle, representing the starting position.
 * @param { Point } b   A point on or outside the rectangle, representing the starting position.
 * @returns { Point[]}  Points returned are clockwise from start to end.
 */
PIXI.Rectangle.prototype.pointsBetween = function(a, b) {
  const CSZ = PIXI.Rectangle.CS_ZONES;

  // Assume the point could be outside the rectangle but not inside (which would be undefined).
  const zoneA = this._getEdgeZone(a);
  if ( !zoneA ) return [];
  const zoneB = this._getEdgeZone(b);
  if ( !zoneB ) return [];

  // If on the same wall, return none if end is counterclockwise to start.
  if ( zoneA === zoneB && foundry.utils.orient2dFast(this.center, a, b) <= 0 ) return [];
  let z = zoneA;
  const pts = [];
  for ( let i = 0; i < 4; i += 1) {
    if ( (z & CSZ.LEFT) ) {
      if ( z !== CSZ.TOPLEFT ) pts.push({ x: this.left, y: this.top });
      z = CSZ.TOP;
    } else if ( (z & CSZ.TOP) ) {
      if ( z !== CSZ.TOPRIGHT ) pts.push({ x: this.right, y: this.top });
      z = CSZ.RIGHT;
    } else if ( (z & CSZ.RIGHT) ) {
      if ( z !== CSZ.BOTTOMRIGHT ) pts.push({ x: this.right, y: this.bottom });
      z = CSZ.BOTTOM;
    } else if ( (z & CSZ.BOTTOM) ) {
      if ( z !== CSZ.BOTTOMLEFT ) pts.push({ x: this.left, y: this.bottom });
      z = CSZ.LEFT;
    }
    if ( z & zoneB ) break;
  }
  return pts;
};

/* -------------------------------------------- */

/**
 * Get all intersection points for a segment A|B
 * Intersections are sorted from A to B.
 * @param {Point} a   Endpoint A of the segment
 * @param {Point} b   Endpoint B of the segment
 * @returns {Point[]} Array of intersections or empty if no intersection.
 *  If A|B is parallel to an edge of this rectangle, returns the two furthest points on
 *  the segment A|B that are on the edge.
 *  The return object's t0 property signifies the location of the intersection on segment A|B.
 *  This will be NaN if the segment is a point.
 *  The return object's t1 property signifies the location of the intersection on the rectangle edge.
 *  The t1 value is measured relative to the intersecting edge of the rectangle.
 */
PIXI.Rectangle.prototype.segmentIntersections = function(a, b) {

  // The segment is collinear with a vertical edge
  if ( a.x.almostEqual(b.x) && (a.x.almostEqual(this.left) || a.x.almostEqual(this.right)) ) {
    const minY1 = Math.min(a.y, b.y);
    const minY2 = Math.min(this.top, this.bottom);
    const maxY1 = Math.max(a.y, b.y);
    const maxY2 = Math.max(this.top, this.bottom);
    const minIxY = Math.max(minY1, minY2);
    const maxIxY = Math.min(maxY1, maxY2);

    // Test whether the two segments intersect
    const pointIntersection = minIxY.almostEqual(maxIxY);
    if ( pointIntersection || (minIxY < maxIxY) ) {
      // Determine t-values of the a|b segment intersections (t0) and the rectangle edge (t1).
      const distAB = Math.abs(b.y - a.y);
      const distRect = this.height;
      const y = (b.y - a.y) > 0 ? a.y : b.y;
      const rectY = a.x.almostEqual(this.right) ? this.top : this.bottom;
      const minRes = {x: a.x, y: minIxY, t0: (minIxY - y) / distAB, t1: Math.abs((minIxY - rectY) / distRect)};

      // If true, the a|b segment is nearly a point and t0 is likely NaN.
      if ( pointIntersection ) return [minRes];

      // Return in order nearest a, nearest b
      const maxRes = {x: a.x, y: maxIxY, t0: (maxIxY - y) / distAB, t1: Math.abs((maxIxY - rectY) / distRect)};
      return Math.abs(minIxY - a.y) < Math.abs(maxIxY - a.y)
        ? [minRes, maxRes]
        : [maxRes, minRes];
    }
  }

  // The segment is collinear with a horizontal edge
  else if ( a.y.almostEqual(b.y) && (a.y.almostEqual(this.top) || a.y.almostEqual(this.bottom))) {
    const minX1 = Math.min(a.x, b.x);
    const minX2 = Math.min(this.right, this.left);
    const maxX1 = Math.max(a.x, b.x);
    const maxX2 = Math.max(this.right, this.left);
    const minIxX = Math.max(minX1, minX2);
    const maxIxX = Math.min(maxX1, maxX2);

    // Test whether the two segments intersect
    const pointIntersection = minIxX.almostEqual(maxIxX);
    if ( pointIntersection || (minIxX < maxIxX) ) {
      // Determine t-values of the a|b segment intersections (t0) and the rectangle edge (t1).
      const distAB = Math.abs(b.x - a.x);
      const distRect = this.width;
      const x = (b.x - a.x) > 0 ? a.x : b.x;
      const rectX = a.y.almostEqual(this.top) ? this.left : this.right;
      const minRes = {x: minIxX, y: a.y, t0: (minIxX - x) / distAB, t1: Math.abs((minIxX - rectX) / distRect)};

      // If true, the a|b segment is nearly a point and t0 is likely NaN.
      if ( pointIntersection ) return [minRes];

      // Return in order nearest a, nearest b
      const maxRes = {x: maxIxX, y: a.y, t0: (maxIxX - x) / distAB, t1: Math.abs((maxIxX - rectX) / distRect)};
      return Math.abs(minIxX - a.x) < Math.abs(maxIxX - a.x) ? [minRes, maxRes] : [maxRes, minRes];
    }
  }

  // Follows structure of lineSegmentIntersects
  const zoneA = this._getZone(a);
  const zoneB = this._getZone(b);
  if ( !(zoneA | zoneB) ) return []; // Bitwise OR is 0: both points inside rectangle.

  // Regular AND: one point inside, one outside
  // Otherwise, both points outside
  const zones = !(zoneA && zoneB) ? [zoneA || zoneB] : [zoneA, zoneB];

  // If 2 zones, line likely intersects two edges.
  // It is possible to have a line that starts, for example, at center left and moves to center top.
  // In this case it may not cross the rectangle.
  if ( zones.length === 2 && !this.lineSegmentIntersects(a, b) ) return [];
  const CSZ = PIXI.Rectangle.CS_ZONES;
  const lsi = foundry.utils.lineSegmentIntersects;
  const lli = foundry.utils.lineLineIntersection;
  const { leftEdge, rightEdge, bottomEdge, topEdge } = this;
  const ixs = [];
  for ( const z of zones ) {
    let ix;
    if ( (z & CSZ.LEFT)
      && lsi(leftEdge.A, leftEdge.B, a, b)) ix = lli(a, b, leftEdge.A, leftEdge.B);
    if ( !ix && (z & CSZ.RIGHT)
      && lsi(rightEdge.A, rightEdge.B, a, b)) ix = lli(a, b, rightEdge.A, rightEdge.B);
    if ( !ix && (z & CSZ.TOP)
      && lsi(topEdge.A, topEdge.B, a, b)) ix = lli(a, b, topEdge.A, topEdge.B);
    if ( !ix && (z & CSZ.BOTTOM)
      && lsi(bottomEdge.A, bottomEdge.B, a, b)) ix = lli(a, b, bottomEdge.A, bottomEdge.B);

    // The ix should always be a point by now
    if ( !ix ) throw new Error("PIXI.Rectangle.prototype.segmentIntersections returned an unexpected null point.");
    ixs.push(ix);
  }
  return ixs;
};

/* -------------------------------------------- */

/**
 * Compute the intersection of this Rectangle with some other Rectangle.
 * @param {PIXI.Rectangle} other      Some other rectangle which intersects this one
 * @returns {PIXI.Rectangle}          The intersected rectangle
 */
PIXI.Rectangle.prototype.intersection = function(other) {
  const x0 = this.x < other.x ? other.x : this.x;
  const x1 = this.right > other.right ? other.right : this.right;
  const y0 = this.y < other.y ? other.y : this.y;
  const y1 = this.bottom > other.bottom ? other.bottom : this.bottom;
  return new PIXI.Rectangle(x0, y0, x1 - x0, y1 - y0);
};

/* -------------------------------------------- */

/**
 * Convert this PIXI.Rectangle into a PIXI.Polygon
 * @returns {PIXI.Polygon}      The Rectangle expressed as a PIXI.Polygon
 */
PIXI.Rectangle.prototype.toPolygon = function() {
  const points = [this.left, this.top, this.right, this.top, this.right, this.bottom, this.left, this.bottom];
  return new PIXI.Polygon(points);
};

/* -------------------------------------------- */

/**
 * Get the left edge of this rectangle.
 * The returned edge endpoints are oriented clockwise around the rectangle.
 * @type {{A: Point, B: Point}}
 */
Object.defineProperty(PIXI.Rectangle.prototype, "leftEdge", { get: function() {
  return { A: { x: this.left, y: this.bottom }, B: { x: this.left, y: this.top }};
}});

/* -------------------------------------------- */

/**
 * Get the right edge of this rectangle.
 * The returned edge endpoints are oriented clockwise around the rectangle.
 * @type {{A: Point, B: Point}}
 */
Object.defineProperty(PIXI.Rectangle.prototype, "rightEdge", { get: function() {
  return { A: { x: this.right, y: this.top }, B: { x: this.right, y: this.bottom }};
}});

/* -------------------------------------------- */

/**
 * Get the top edge of this rectangle.
 * The returned edge endpoints are oriented clockwise around the rectangle.
 * @type {{A: Point, B: Point}}
 */
Object.defineProperty(PIXI.Rectangle.prototype, "topEdge", { get: function() {
  return { A: { x: this.left, y: this.top }, B: { x: this.right, y: this.top }};
}});

/* -------------------------------------------- */

/**
 * Get the bottom edge of this rectangle.
 * The returned edge endpoints are oriented clockwise around the rectangle.
 * @type {{A: Point, B: Point}}
 */
Object.defineProperty(PIXI.Rectangle.prototype, "bottomEdge", { get: function() {
  return { A: { x: this.right, y: this.bottom }, B: { x: this.left, y: this.bottom }};
}});

/* -------------------------------------------- */

/**
 * Calculate the rectangle Zone for a given point located around or in the rectangle.
 * https://en.wikipedia.org/wiki/Cohen%E2%80%93Sutherland_algorithm
 *
 * @param {Point} p     Point to test for location relative to the rectangle
 * @returns {PIXI.Rectangle.CS_ZONES}
 */
PIXI.Rectangle.prototype._getZone = function(p) {
  const CSZ = PIXI.Rectangle.CS_ZONES;
  let code = CSZ.INSIDE;

  if ( p.x < this.x ) code |= CSZ.LEFT;
  else if ( p.x > this.right ) code |= CSZ.RIGHT;

  if ( p.y < this.y ) code |= CSZ.TOP;
  else if ( p.y > this.bottom ) code |= CSZ.BOTTOM;

  return code;
};

/**
 * Test whether a line segment AB intersects this rectangle.
 * @param {Point} a                       The first endpoint of segment AB
 * @param {Point} b                       The second endpoint of segment AB
 * @param {object} [options]              Options affecting the intersect test.
 * @param {boolean} [options.inside]      If true, a line contained within the rectangle will
 *                                        return true.
 * @returns {boolean} True if intersects.
 */
PIXI.Rectangle.prototype.lineSegmentIntersects = function(a, b, { inside = false } = {}) {
  const zoneA = this._getZone(a);
  const zoneB = this._getZone(b);

  if ( !(zoneA | zoneB) ) return inside; // Bitwise OR is 0: both points inside rectangle.
  if ( zoneA & zoneB ) return false; // Bitwise AND is not 0: both points share outside zone
  if ( !(zoneA && zoneB) ) return true; // Regular AND: one point inside, one outside

  // Line likely intersects, but some possibility that the line starts at, say, center left
  // and moves to center top which means it may or may not cross the rectangle
  const CSZ = PIXI.Rectangle.CS_ZONES;
  const lsi = foundry.utils.lineSegmentIntersects;

  // If the zone is a corner, like top left, test one side and then if not true, test
  // the other. If the zone is on a side, like left, just test that side.
  const leftEdge = this.leftEdge;
  if ( (zoneA & CSZ.LEFT) && lsi(leftEdge.A, leftEdge.B, a, b) ) return true;

  const rightEdge = this.rightEdge;
  if ( (zoneA & CSZ.RIGHT) && lsi(rightEdge.A, rightEdge.B, a, b) ) return true;

  const topEdge = this.topEdge;
  if ( (zoneA & CSZ.TOP) && lsi(topEdge.A, topEdge.B, a, b) ) return true;

  const bottomEdge = this.bottomEdge;
  if ( (zoneA & CSZ.BOTTOM ) && lsi(bottomEdge.A, bottomEdge.B, a, b) ) return true;

  return false;
};

/* -------------------------------------------- */

/**
 * Intersect this PIXI.Rectangle with a PIXI.Polygon.
 * Currently uses the clipper library.
 * In the future we may replace this with more specialized logic which uses the line-line intersection formula.
 * @param {PIXI.Polygon} polygon      A PIXI.Polygon
 * @param {object} [options]          Options which configure how the intersection is computed
 * @param {number} [options.clipType]             The clipper clip type
 * @param {number} [options.scalingFactor]        A scaling factor passed to Polygon#toClipperPoints for precision
 * @param {string} [options.weilerAtherton=true]  Use the Weiler-Atherton algorithm. Otherwise, use Clipper.
 * @param {boolean} [options.canMutate]           If the WeilerAtherton constructor could mutate or not
 * @returns {PIXI.Polygon}       The intersected polygon
 */
PIXI.Rectangle.prototype.intersectPolygon = function(polygon, {clipType, scalingFactor, canMutate, weilerAtherton=true}={}) {
  if ( !this.width || !this.height ) return new PIXI.Polygon([]);
  clipType ??= ClipperLib.ClipType.ctIntersection;

  // Use Weiler-Atherton for efficient intersection or union
  if ( weilerAtherton && polygon.isPositive ) {
    const res = WeilerAthertonClipper.combine(polygon, this, {clipType, canMutate, scalingFactor});
    if ( !res.length ) return new PIXI.Polygon([]);
    return res[0];
  }

  // Use Clipper polygon intersection
  return polygon.intersectPolygon(this.toPolygon(), {clipType, canMutate, scalingFactor});
};

/* -------------------------------------------- */

/**
 * Intersect this PIXI.Rectangle with an array of ClipperPoints. Currently, uses the clipper library.
 * In the future we may replace this with more specialized logic which uses the line-line intersection formula.
 * @param {ClipperPoint[]} clipperPoints An array of ClipperPoints generated by PIXI.Polygon.toClipperPoints()
 * @param {object} [options]            Options which configure how the intersection is computed
 * @param {number} [options.clipType]       The clipper clip type
 * @param {number} [options.scalingFactor]  A scaling factor passed to Polygon#toClipperPoints to preserve precision
 * @returns {PIXI.Polygon|null}         The intersected polygon or null if no solution was present
 */
PIXI.Rectangle.prototype.intersectClipper = function(clipperPoints, {clipType, scalingFactor}={}) {
  if ( !this.width || !this.height ) return [];
  return this.toPolygon().intersectPolygon(clipperPoints, {clipType, scalingFactor});
};

/* -------------------------------------------- */

/**
 * Determine whether some other Rectangle overlaps with this one.
 * This check differs from the parent class Rectangle#intersects test because it is true for adjacency (zero area).
 * @param {PIXI.Rectangle} other  Some other rectangle against which to compare
 * @returns {boolean}             Do the rectangles overlap?
 */
PIXI.Rectangle.prototype.overlaps = function(other) {
  return (other.right >= this.left)
    && (other.left <= this.right)
    && (other.bottom >= this.top)
    && (other.top <= this.bottom);
};

/* -------------------------------------------- */

/**
 * Normalize the width and height of the rectangle in-place, enforcing that those dimensions be positive.
 * @returns {PIXI.Rectangle}
 */
PIXI.Rectangle.prototype.normalize = function() {
  if ( this.width < 0 ) {
    this.x += this.width;
    this.width = Math.abs(this.width);
  }
  if ( this.height < 0 ) {
    this.y += this.height;
    this.height = Math.abs(this.height);
  }
  return this;
};

/* -------------------------------------------- */

/**
 * Fits this rectangle around this rectangle rotated around the given pivot counterclockwise by the given angle in radians.
 * @param {number} radians           The angle of rotation.
 * @param {PIXI.Point} [pivot]       An optional pivot point (normalized).
 * @returns {this}                   This rectangle.
 */
PIXI.Rectangle.prototype.rotate = function(radians, pivot) {
  if ( radians === 0 ) return this;
  return this.constructor.fromRotation(this.x, this.y, this.width, this.height, radians, pivot, this);
};

/* -------------------------------------------- */

/**
 * Create normalized rectangular bounds given a rectangle shape and an angle of central rotation.
 * @param {number} x                 The top-left x-coordinate of the un-rotated rectangle
 * @param {number} y                 The top-left y-coordinate of the un-rotated rectangle
 * @param {number} width             The width of the un-rotated rectangle
 * @param {number} height            The height of the un-rotated rectangle
 * @param {number} radians           The angle of rotation about the center
 * @param {PIXI.Point} [pivot]       An optional pivot point (if not provided, the pivot is the centroid)
 * @param {PIXI.Rectangle} [_outRect] (Internal)
 * @returns {PIXI.Rectangle}         The constructed rotated rectangle bounds
 */
PIXI.Rectangle.fromRotation = function(x, y, width, height, radians, pivot, _outRect) {
  const cosAngle = Math.cos(radians);
  const sinAngle = Math.sin(radians);

  // Create the output rect if necessary
  _outRect ??= new PIXI.Rectangle();

  // Is it possible to do with the simple computation?
  if ( pivot === undefined || ((pivot.x === 0.5) && (pivot.y === 0.5)) ) {
    _outRect.height = (height * Math.abs(cosAngle)) + (width * Math.abs(sinAngle));
    _outRect.width = (height * Math.abs(sinAngle)) + (width * Math.abs(cosAngle));
    _outRect.x = x + ((width - _outRect.width) / 2);
    _outRect.y = y + ((height - _outRect.height) / 2);
    return _outRect;
  }

  // Calculate the pivot point in absolute coordinates
  const pivotX = x + (width * pivot.x);
  const pivotY = y + (height * pivot.y);

  // Calculate vectors from pivot to the rectangle's corners
  const tlX = x - pivotX;
  const tlY = y - pivotY;
  const trX = x + width - pivotX;
  const trY = y - pivotY;
  const blX = x - pivotX;
  const blY = y + height - pivotY;
  const brX = x + width - pivotX;
  const brY = y + height - pivotY;

  // Apply rotation to the vectors
  const rTlX = (cosAngle * tlX) - (sinAngle * tlY);
  const rTlY = (sinAngle * tlX) + (cosAngle * tlY);
  const rTrX = (cosAngle * trX) - (sinAngle * trY);
  const rTrY = (sinAngle * trX) + (cosAngle * trY);
  const rBlX = (cosAngle * blX) - (sinAngle * blY);
  const rBlY = (sinAngle * blX) + (cosAngle * blY);
  const rBrX = (cosAngle * brX) - (sinAngle * brY);
  const rBrY = (sinAngle * brX) + (cosAngle * brY);

  // Find the new corners of the bounding rectangle
  const minX = Math.min(rTlX, rTrX, rBlX, rBrX);
  const minY = Math.min(rTlY, rTrY, rBlY, rBrY);
  const maxX = Math.max(rTlX, rTrX, rBlX, rBrX);
  const maxY = Math.max(rTlY, rTrY, rBlY, rBrY);

  // Assign the new computed bounding box
  _outRect.x = pivotX + minX;
  _outRect.y = pivotY + minY;
  _outRect.width = maxX - minX;
  _outRect.height = maxY - minY;
  return _outRect;
};

/**
 * @typedef {foundry.utils.Collection} EffectsCollection
 */

/**
 * A container group which contains visual effects rendered above the primary group.
 *
 * TODO:
 *  The effects canvas group is now only performing shape initialization, logic that needs to happen at
 *  the placeable or object level is now their burden.
 *  - [DONE] Adding or removing a source from the EffectsCanvasGroup collection.
 *  - [TODO] A change in a darkness source should re-initialize all overlaping light and vision source.
 *
 * ### Hook Events
 * - {@link hookEvents.lightingRefresh}
 *
 * @category - Canvas
 */
class EffectsCanvasGroup extends CanvasGroupMixin(PIXI.Container) {

  /**
   * The name of the darkness level animation.
   * @type {string}
   */
  static #DARKNESS_ANIMATION_NAME = "lighting.animateDarkness";

  /**
   * Whether to currently animate light sources.
   * @type {boolean}
   */
  animateLightSources = true;

  /**
   * Whether to currently animate vision sources.
   * @type {boolean}
   */
  animateVisionSources = true;

  /**
   * A mapping of light sources which are active within the rendered Scene.
   * @type {EffectsCollection<string, PointLightSource>}
   */
  lightSources = new foundry.utils.Collection();

  /**
   * A mapping of darkness sources which are active within the rendered Scene.
   * @type {EffectsCollection<string, PointDarknessSource>}
   */
  darknessSources = new foundry.utils.Collection();

  /**
   * A Collection of vision sources which are currently active within the rendered Scene.
   * @type {EffectsCollection<string, PointVisionSource>}
   */
  visionSources = new foundry.utils.Collection();

  /**
   * A set of vision mask filters used in visual effects group
   * @type {Set<VisualEffectsMaskingFilter>}
   */
  visualEffectsMaskingFilters = new Set();

  /* -------------------------------------------- */

  /**
   * Iterator for all light and darkness sources.
   * @returns {Generator<PointDarknessSource|PointLightSource, void, void>}
   * @yields foundry.canvas.sources.PointDarknessSource|foundry.canvas.sources.PointLightSource
   */
  * allSources() {
    for ( const darknessSource of this.darknessSources ) yield darknessSource;
    for ( const lightSource of this.lightSources ) yield lightSource;
  }

  /* -------------------------------------------- */

  /** @override */
  _createLayers() {
    /**
     * A layer of background alteration effects which change the appearance of the primary group render texture.
     * @type {CanvasBackgroundAlterationEffects}
     */
    this.background = this.addChild(new CanvasBackgroundAlterationEffects());

    /**
     * A layer which adds illumination-based effects to the scene.
     * @type {CanvasIlluminationEffects}
     */
    this.illumination = this.addChild(new CanvasIlluminationEffects());

    /**
     * A layer which adds color-based effects to the scene.
     * @type {CanvasColorationEffects}
     */
    this.coloration = this.addChild(new CanvasColorationEffects());

    /**
     * A layer which adds darkness effects to the scene.
     * @type {CanvasDarknessEffects}
     */
    this.darkness = this.addChild(new CanvasDarknessEffects());

    return {
      background: this.background,
      illumination: this.illumination,
      coloration: this.coloration,
      darkness: this.darkness
    };
  }

  /* -------------------------------------------- */

  /**
   * Clear all effects containers and animated sources.
   */
  clearEffects() {
    this.background.clear();
    this.illumination.clear();
    this.coloration.clear();
    this.darkness.clear();
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    // Draw each component layer
    await this.background.draw();
    await this.illumination.draw();
    await this.coloration.draw();
    await this.darkness.draw();

    // Call hooks
    Hooks.callAll("drawEffectsCanvasGroup", this);

    // Activate animation of drawn objects
    this.activateAnimation();
  }

  /* -------------------------------------------- */
  /*  Perception Management Methods               */
  /* -------------------------------------------- */

  /**
   * Initialize positive light sources which exist within the active Scene.
   * Packages can use the "initializeLightSources" hook to programmatically add light sources.
   */
  initializeLightSources() {
    for ( let source of this.lightSources ) source.initialize();
    Hooks.callAll("initializeLightSources", this);
  }

  /* -------------------------------------------- */

  /**
   * Re-initialize the shapes of all darkness sources in the Scene.
   * This happens before initialization of light sources because darkness sources contribute additional edges which
   * limit perception.
   * Packages can use the "initializeDarknessSources" hook to programmatically add darkness sources.
   */
  initializeDarknessSources() {
    for ( let source of this.darknessSources ) source.initialize();
    Hooks.callAll("initializeDarknessSources", this);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the state and uniforms of all light sources and darkness sources objects.
   */
  refreshLightSources() {
    for ( const source of this.allSources() ) source.refresh();
    // FIXME: We need to refresh the field of an AmbientLight only after the initialization of the light source when
    // the shape of the source could have changed. We don't need to refresh all fields whenever lighting is refreshed.
    canvas.lighting.refreshFields();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the state and uniforms of all VisionSource objects.
   */
  refreshVisionSources() {
    for ( const visionSource of this.visionSources ) visionSource.refresh();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the active display of lighting.
   */
  refreshLighting() {

    // Apply illumination and visibility background color change
    this.illumination.backgroundColor = canvas.colors.background;
    if ( this.illumination.darknessLevelMeshes.clearColor[0] !== canvas.environment.darknessLevel ) {
      this.illumination.darknessLevelMeshes.clearColor[0] = canvas.environment.darknessLevel;
      this.illumination.invalidateDarknessLevelContainer(true);
    }
    const v = canvas.visibility.filter;
    if ( v ) {
      v.uniforms.visionTexture = canvas.masks.vision.renderTexture;
      v.uniforms.primaryTexture = canvas.primary.renderTexture;
      canvas.colors.fogExplored.applyRGB(v.uniforms.exploredColor);
      canvas.colors.fogUnexplored.applyRGB(v.uniforms.unexploredColor);
      canvas.colors.background.applyRGB(v.uniforms.backgroundColor);
    }

    // Clear effects
    canvas.effects.clearEffects();

    // Add effect meshes for active light and darkness sources
    for ( const source of this.allSources() ) this.#addLightEffect(source);

    // Add effect meshes for active vision sources
    for ( const visionSource of this.visionSources ) this.#addVisionEffect(visionSource);

    // Update vision filters state
    this.background.vision.filter.enabled = !!this.background.vision.children.length;
    this.background.visionPreferred.filter.enabled = !!this.background.visionPreferred.children.length;

    // Hide the background and/or coloration layers if possible
    const lightingOptions = canvas.visibility.visionModeData.activeLightingOptions;
    this.background.vision.visible = (this.background.vision.children.length > 0);
    this.background.visionPreferred.visible = (this.background.visionPreferred.children.length > 0);
    this.background.lighting.visible = (this.background.lighting.children.length > 0)
      || (lightingOptions.background?.postProcessingModes?.length > 0);
    this.coloration.visible = (this.coloration.children.length > 1)
      || (lightingOptions.coloration?.postProcessingModes?.length > 0);

    // Call hooks
    Hooks.callAll("lightingRefresh", this);
  }

  /* -------------------------------------------- */

  /**
   * Add a vision source to the effect layers.
   * @param {RenderedEffectSource & PointVisionSource} source     The vision source to add mesh layers
   */
  #addVisionEffect(source) {
    if ( !source.active || (source.radius <= 0) ) return;
    const meshes = source.drawMeshes();
    if ( meshes.background ) {
      // Is this vision source background need to be rendered into the preferred vision container, over other VS?
      const parent = source.preferred ? this.background.visionPreferred : this.background.vision;
      parent.addChild(meshes.background);
    }
    if ( meshes.illumination ) this.illumination.lights.addChild(meshes.illumination);
    if ( meshes.coloration ) this.coloration.addChild(meshes.coloration);
  }

  /* -------------------------------------------- */

  /**
   * Add a light source or a darkness source to the effect layers
   * @param {RenderedEffectSource & BaseLightSource} source   The light or darkness source to add to the effect layers.
   */
  #addLightEffect(source) {
    if ( !source.active ) return;
    const meshes = source.drawMeshes();
    if ( meshes.background ) this.background.lighting.addChild(meshes.background);
    if ( meshes.illumination ) this.illumination.lights.addChild(meshes.illumination);
    if ( meshes.coloration ) this.coloration.addChild(meshes.coloration);
    if ( meshes.darkness ) this.darkness.addChild(meshes.darkness);
  }

  /* -------------------------------------------- */

  /**
   * Test whether the point is inside light.
   * @param {Point} point         The point.
   * @param {number} elevation    The elevation of the point.
   * @returns {boolean}           Is inside light?
   */
  testInsideLight(point, elevation) {

    // First test light source excluding the global light source
    for ( const lightSource of this.lightSources ) {
      if ( !lightSource.active || (lightSource instanceof foundry.canvas.sources.GlobalLightSource) ) continue;
      if ( lightSource.shape.contains(point.x, point.y) ) return true;
    }

    // Second test Global Illumination and Darkness Level meshes
    const globalLightSource = canvas.environment.globalLightSource;
    if ( !globalLightSource.active ) return false;
    const {min, max} = globalLightSource.data.darkness;
    const darknessLevel = this.getDarknessLevel(point, elevation);
    return (darknessLevel >= min) && (darknessLevel <= max);
  }

  /* -------------------------------------------- */

  /**
   * Test whether the point is inside darkness.
   * @param {Point} point         The point.
   * @param {number} elevation    The elevation of the point.
   * @returns {boolean}           Is inside a darkness?
   */
  testInsideDarkness({x, y}, elevation) {
    for ( const source of this.darknessSources ) {
      if ( !source.active || source.isPreview ) continue;
      for ( let dx = -1; dx <= 1; dx += 1 ) {
        for ( let dy = -1; dy <= 1; dy += 1 ) {
          if ( source.shape.contains(x + dx, y + dy) ) return true;
        }
      }
    }
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Get the darkness level at the given point.
   * @param {Point} point         The point.
   * @param {number} elevation    The elevation of the point.
   * @returns {number}            The darkness level.
   */
  getDarknessLevel(point, elevation) {
    const darknessLevelMeshes = canvas.effects.illumination.darknessLevelMeshes.children;
    for ( let i = darknessLevelMeshes.length - 1; i >= 0; i-- ) {
      const darknessLevelMesh = darknessLevelMeshes[i];
      if ( darknessLevelMesh.region.testPoint(point, elevation) ) {
        return darknessLevelMesh.shader.uniforms.darknessLevel;
      }
    }
    return canvas.environment.darknessLevel;
  }

  /* -------------------------------------------- */

  /** @override */
  async _tearDown(options) {
    CanvasAnimation.terminateAnimation(EffectsCanvasGroup.#DARKNESS_ANIMATION_NAME);
    this.deactivateAnimation();
    this.darknessSources.clear();
    this.lightSources.clear();
    for ( const c of this.children ) {
      if ( c.clear ) c.clear();
      else if ( c.tearDown ) await c.tearDown();
      else c.destroy();
    }
    this.visualEffectsMaskingFilters.clear();
  }

  /* -------------------------------------------- */

  /**
   * Activate vision masking for visual effects
   * @param {boolean} [enabled=true]    Whether to enable or disable vision masking
   */
  toggleMaskingFilters(enabled=true) {
    for ( const f of this.visualEffectsMaskingFilters ) {
      f.uniforms.enableVisionMasking = enabled;
    }
  }

  /* -------------------------------------------- */

  /**
   * Activate post-processing effects for a certain effects channel.
   * @param {string} filterMode                     The filter mode to target.
   * @param {string[]} [postProcessingModes=[]]     The post-processing modes to apply to this filter.
   * @param {Object} [uniforms={}]                  The uniforms to update.
   */
  activatePostProcessingFilters(filterMode, postProcessingModes=[], uniforms={}) {
    for ( const f of this.visualEffectsMaskingFilters ) {
      if ( f.uniforms.mode === filterMode ) {
        f.updatePostprocessModes(postProcessingModes, uniforms);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Reset post-processing modes on all Visual Effects masking filters.
   */
  resetPostProcessingFilters() {
    for ( const f of this.visualEffectsMaskingFilters ) {
      f.reset();
    }
  }

  /* -------------------------------------------- */
  /*  Animation Management                        */
  /* -------------------------------------------- */

  /**
   * Activate light source animation for AmbientLight objects within this layer
   */
  activateAnimation() {
    this.deactivateAnimation();
    if ( game.settings.get("core", "lightAnimation") === false ) return;
    canvas.app.ticker.add(this.#animateSources, this);
  }

  /* -------------------------------------------- */

  /**
   * Deactivate light source animation for AmbientLight objects within this layer
   */
  deactivateAnimation() {
    canvas.app.ticker.remove(this.#animateSources, this);
  }

  /* -------------------------------------------- */

  /**
   * The ticker handler which manages animation delegation
   * @param {number} dt   Delta time
   * @private
   */
  #animateSources(dt) {

    // Animate light and darkness sources
    if ( this.animateLightSources ) {
      for ( const source of this.allSources() ) {
        source.animate(dt);
      }
    }

    // Animate vision sources
    if ( this.animateVisionSources ) {
      for ( const source of this.visionSources.values() ) {
        source.animate(dt);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Animate a smooth transition of the darkness overlay to a target value.
   * Only begin animating if another animation is not already in progress.
   * @param {number} target     The target darkness level between 0 and 1
   * @param {number} duration   The desired animation time in milliseconds. Default is 10 seconds
   * @returns {Promise}         A Promise which resolves once the animation is complete
   */
  async animateDarkness(target=1.0, {duration=10000}={}) {
    CanvasAnimation.terminateAnimation(EffectsCanvasGroup.#DARKNESS_ANIMATION_NAME);
    if ( target === canvas.environment.darknessLevel ) return false;
    if ( duration <= 0 ) return canvas.environment.initialize({environment: {darknessLevel: target}});

    // Update with an animation
    const animationData = [{
      parent: {darkness: canvas.environment.darknessLevel},
      attribute: "darkness",
      to: Math.clamp(target, 0, 1)
    }];
    return CanvasAnimation.animate(animationData, {
      name: EffectsCanvasGroup.#DARKNESS_ANIMATION_NAME,
      duration: duration,
      ontick: (dt, animation) =>
        canvas.environment.initialize({environment: {darknessLevel: animation.attributes[0].parent.darkness}})
    }).then(completed => {
      if ( !completed ) canvas.environment.initialize({environment: {darknessLevel: target}});
    });
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get visibility() {
    const msg = "EffectsCanvasGroup#visibility has been deprecated and moved to " +
      "Canvas#visibility.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return canvas.visibility;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  get globalLightSource() {
    const msg = "EffectsCanvasGroup#globalLightSource has been deprecated and moved to " +
      "EnvironmentCanvasGroup#globalLightSource.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return canvas.environment.globalLightSource;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  updateGlobalLightSource() {
    const msg = "EffectsCanvasGroup#updateGlobalLightSource has been deprecated and is part of " +
      "EnvironmentCanvasGroup#initialize workflow.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    canvas.environment.initialize();
  }
}

/**
 * A container group which contains the primary canvas group and the effects canvas group.
 *
 * @category - Canvas
 */
class EnvironmentCanvasGroup extends CanvasGroupMixin(PIXI.Container) {
  constructor(...args) {
    super(...args);
    this.eventMode = "static";

    /**
     * The global light source attached to the environment
     * @type {GlobalLightSource}
     */
    Object.defineProperty(this, "globalLightSource", {
      value: new CONFIG.Canvas.globalLightSourceClass({object: this, sourceId: "globalLight"}),
      configurable: false,
      enumerable: true,
      writable: false
    });
  }

  /** @override */
  static groupName = "environment";

  /** @override */
  static tearDownChildren = false;

  /**
   * The scene darkness level.
   * @type {number}
   */
  #darknessLevel;

  /**
   * Colors exposed by the manager.
   * @enum {Color}
   */
  colors = {
    darkness: undefined,
    halfdark: undefined,
    background: undefined,
    dim: undefined,
    bright: undefined,
    ambientBrightest: undefined,
    ambientDaylight: undefined,
    ambientDarkness: undefined,
    sceneBackground: undefined,
    fogExplored: undefined,
    fogUnexplored: undefined
  };

  /**
   * Weights used by the manager to compute colors.
   * @enum {number}
   */
  weights = {
    dark: undefined,
    halfdark: undefined,
    dim: undefined,
    bright: undefined
  };

  /**
   * Fallback colors.
   * @enum {Color}
   */
  static #fallbackColors = {
    darknessColor: 0x242448,
    daylightColor: 0xEEEEEE,
    brightestColor: 0xFFFFFF,
    backgroundColor: 0x999999,
    fogUnexplored: 0x000000,
    fogExplored: 0x000000
  };

  /**
   * Contains a list of subscribed function for darkness handler.
   * @type {PIXI.EventBoundary}
   */
  #eventBoundary;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Get the darkness level of this scene.
   * @returns {number}
   */
  get darknessLevel() {
    return this.#darknessLevel;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    await super._draw(options);
    this.#eventBoundary = new PIXI.EventBoundary(this);
    this.initialize();
  }

  /* -------------------------------------------- */
  /*  Ambience Methods                            */
  /* -------------------------------------------- */

  /**
   * Initialize the scene environment options.
   * @param {object} [config={}]
   * @param {ColorSource} [config.backgroundColor]              The background canvas color
   * @param {ColorSource} [config.brightestColor]               The brightest ambient color
   * @param {ColorSource} [config.darknessColor]                The color of darkness
   * @param {ColorSource} [config.daylightColor]                The ambient daylight color
   * @param {ColorSource} [config.fogExploredColor]             The color applied to explored areas
   * @param {ColorSource} [config.fogUnexploredColor]           The color applied to unexplored areas
   * @param {SceneEnvironmentData} [config.environment]         The scene environment data
   * @fires PIXI.FederatedEvent type: "darknessChange" - event: {environmentData: {darknessLevel, priorDarknessLevel}}
   */
  initialize({backgroundColor, brightestColor, darknessColor, daylightColor, fogExploredColor,
    fogUnexploredColor, darknessLevel, environment={}}={}) {
    const scene = canvas.scene;

    // Update base ambient colors, and darkness level
    const fbc = EnvironmentCanvasGroup.#fallbackColors;
    this.colors.ambientDarkness = Color.from(darknessColor ?? CONFIG.Canvas.darknessColor ?? fbc.darknessColor);
    this.colors.ambientDaylight = Color.from(daylightColor
      ?? (scene.tokenVision ? (CONFIG.Canvas.daylightColor ?? fbc.daylightColor) : 0xFFFFFF));
    this.colors.ambientBrightest = Color.from(brightestColor ?? CONFIG.Canvas.brightestColor ?? fbc.brightestColor);

    /**
     * @deprecated since v12
     */
    if ( darknessLevel !== undefined ) {
      const msg = "config.darknessLevel parameter into EnvironmentCanvasGroup#initialize is deprecated. " +
        "You should pass the darkness level into config.environment.darknessLevel";
      foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
      environment.darknessLevel = darknessLevel;
    }

    // Darkness Level Control
    const priorDarknessLevel = this.#darknessLevel ?? 0;
    const dl = environment.darknessLevel ?? scene.environment.darknessLevel;
    const darknessChanged = (dl !== this.#darknessLevel);
    this.#darknessLevel = scene.environment.darknessLevel = dl;

    // Update weights
    Object.assign(this.weights, CONFIG.Canvas.lightLevels ?? {
      dark: 0,
      halfdark: 0.5,
      dim: 0.25,
      bright: 1
    });

    // Compute colors
    this.#configureColors({fogExploredColor, fogUnexploredColor, backgroundColor});

    // Configure the scene environment
    this.#configureEnvironment(environment);

    // Update primary cached container and renderer clear color with scene background color
    canvas.app.renderer.background.color = this.colors.rendererBackground;
    canvas.primary._backgroundColor = this.colors.sceneBackground.rgb;

    // Dispatching the darkness change event
    if ( darknessChanged ) {
      const event = new PIXI.FederatedEvent(this.#eventBoundary);
      event.type = "darknessChange";
      event.environmentData = {
        darknessLevel: this.#darknessLevel,
        priorDarknessLevel
      };
      this.dispatchEvent(event);
    }

    // Push a perception update to refresh lighting and sources with the new computed color values
    canvas.perception.update({
      refreshPrimary: true,
      refreshLighting: true,
      refreshVision: true
    });
  }

  /* -------------------------------------------- */

  /**
   * Configure all colors pertaining to a scene.
   * @param {object} [options={}]                      Preview options.
   * @param {ColorSource} [options.fogExploredColor]   A preview fog explored color.
   * @param {ColorSource} [options.fogUnexploredColor] A preview fog unexplored color.
   * @param {ColorSource} [options.backgroundColor]    The background canvas color.
   */
  #configureColors({fogExploredColor, fogUnexploredColor, backgroundColor}={}) {
    const scene = canvas.scene;
    const fbc = EnvironmentCanvasGroup.#fallbackColors;

    // Compute the middle ambient color
    this.colors.background = this.colors.ambientDarkness.mix(this.colors.ambientDaylight, 1.0 - this.darknessLevel);

    // Compute dark ambient colors
    this.colors.darkness = this.colors.ambientDarkness.mix(this.colors.background, this.weights.dark);
    this.colors.halfdark = this.colors.darkness.mix(this.colors.background, this.weights.halfdark);

    // Compute light ambient colors
    this.colors.bright =
      this.colors.background.mix(this.colors.ambientBrightest, this.weights.bright);
    this.colors.dim = this.colors.background.mix(this.colors.bright, this.weights.dim);

    // Compute fog colors
    const cfg = CONFIG.Canvas;
    const uc = Color.from(fogUnexploredColor ?? scene.fog.colors.unexplored ?? cfg.unexploredColor ?? fbc.fogUnexplored);
    this.colors.fogUnexplored = this.colors.background.multiply(uc);
    const ec = Color.from(fogExploredColor ?? scene.fog.colors.explored ?? cfg.exploredColor ?? fbc.fogExplored);
    this.colors.fogExplored = this.colors.background.multiply(ec);

    // Compute scene background color
    const sceneBG = Color.from(backgroundColor ?? scene?.backgroundColor ?? fbc.backgroundColor);
    this.colors.sceneBackground = sceneBG;
    this.colors.rendererBackground = sceneBG.multiply(this.colors.background);
  }

  /* -------------------------------------------- */

  /**
   * Configure the ambience filter for scene ambient lighting.
   * @param {SceneEnvironmentData} [environment] The scene environment data object.
   */
  #configureEnvironment(environment={}) {
    const currentEnvironment = canvas.scene.toObject().environment;

    /**
     * @type {SceneEnvironmentData}
     */
    const data = foundry.utils.mergeObject(environment, currentEnvironment, {
      inplace: false,
      insertKeys: true,
      insertValues: true,
      overwrite: false
    });

    // First configure the ambience filter
    this.#configureAmbienceFilter(data);

    // Then configure the global light
    this.#configureGlobalLight(data);
  }

  /* -------------------------------------------- */

  /**
   * Configure the ambience filter.
   * @param {SceneEnvironmentData} environment
   * @param {boolean} environment.cycle                  The cycle option.
   * @param {EnvironmentData} environment.base           The base environement data.
   * @param {EnvironmentData} environment.dark           The dark environment data.
   */
  #configureAmbienceFilter({cycle, base, dark}) {
    const ambienceFilter = canvas.primary._ambienceFilter;
    if ( !ambienceFilter ) return;
    const u = ambienceFilter.uniforms;

    // Assigning base ambience parameters
    const bh = Color.fromHSL([base.hue, 1, 0.5]).linear;
    Color.applyRGB(bh, u.baseTint);
    u.baseLuminosity = base.luminosity;
    u.baseShadows = base.shadows;
    u.baseIntensity = base.intensity;
    u.baseSaturation = base.saturation;
    const baseAmbienceHasEffect = (base.luminosity !== 0) || (base.shadows > 0)
      || (base.intensity > 0) || (base.saturation !== 0);

    // Assigning dark ambience parameters
    const dh = Color.fromHSL([dark.hue, 1, 0.5]).linear;
    Color.applyRGB(dh, u.darkTint);
    u.darkLuminosity = dark.luminosity;
    u.darkShadows = dark.shadows;
    u.darkIntensity = dark.intensity;
    u.darkSaturation = dark.saturation;
    const darkAmbienceHasEffect = ((dark.luminosity !== 0) || (dark.shadows > 0)
      || (dark.intensity > 0) || (dark.saturation !== 0)) && cycle;

    // Assigning the cycle option
    u.cycle = cycle;

    // Darkness level texture
    u.darknessLevelTexture = canvas.effects.illumination.renderTexture;

    // Enable ambience filter if it is impacting visuals
    ambienceFilter.enabled = baseAmbienceHasEffect || darkAmbienceHasEffect;
  }

  /* -------------------------------------------- */

  /**
   * Configure the global light.
   * @param {SceneEnvironmentData} environment
   * @param {GlobalLightData} environment.globalLight
   */
  #configureGlobalLight({globalLight}) {
    const maxR = canvas.dimensions.maxR * 1.2;
    const globalLightData = foundry.utils.mergeObject({
      z: -Infinity,
      elevation: Infinity,
      dim: globalLight.bright ? 0 : maxR,
      bright: globalLight.bright ? maxR : 0,
      disabled: !globalLight.enabled
    }, globalLight, {overwrite: false});
    this.globalLightSource.initialize(globalLightData);
    this.globalLightSource.add();
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get darknessPenalty() {
    const msg = "EnvironmentCanvasGroup#darknessPenalty is deprecated without replacement. " +
      "The darkness penalty is no longer applied on light and vision sources.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return 0;
  }
}

/**
 * A specialized canvas group for rendering hidden containers before all others (like masks).
 * @extends {PIXI.Container}
 */
class HiddenCanvasGroup extends CanvasGroupMixin(PIXI.Container) {
  constructor() {
    super();
    this.eventMode = "none";
    this.#createMasks();
  }

  /**
   * The container which hold masks.
   * @type {PIXI.Container}
   */
  masks = new PIXI.Container();

  /** @override */
  static groupName = "hidden";

  /* -------------------------------------------- */

  /**
   * Add a mask to this group.
   * @param {string} name                           Name of the mask.
   * @param {PIXI.DisplayObject} displayObject      Display object to add.
   * @param {number|undefined} [position=undefined] Position of the mask.
   */
  addMask(name, displayObject, position) {
    if ( !((typeof name === "string") && (name.length > 0)) ) {
      throw new Error(`Adding mask failed. Name ${name} is invalid.`);
    }
    if ( !displayObject.clear ) {
      throw new Error("A mask container must implement a clear method.");
    }
    // Add the mask to the dedicated `masks` container
    this.masks[name] = position
      ? this.masks.addChildAt(displayObject, position)
      : this.masks.addChild(displayObject);
  }

  /* -------------------------------------------- */

  /**
   * Invalidate the masks: flag them for rerendering.
   */
  invalidateMasks() {
    for ( const mask of this.masks.children ) {
      if ( !(mask instanceof CachedContainer) ) continue;
      mask.renderDirty = true;
    }
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    this.invalidateMasks();
    this.addChild(this.masks);
    await this.#drawMasks();
    await super._draw(options);
  }

  /* -------------------------------------------- */

  /**
   * Perform necessary draw operations.
   */
  async #drawMasks() {
    await this.masks.vision.draw();
  }

  /* -------------------------------------------- */

  /**
   * Attach masks container to this canvas layer and create tile occlusion, vision masks and depth mask.
   */
  #createMasks() {
    // The canvas scissor mask is the first thing to render
    const canvas = new PIXI.LegacyGraphics();
    this.addMask("canvas", canvas);

    // The scene scissor mask
    const scene = new PIXI.LegacyGraphics();
    this.addMask("scene", scene);

    // Then we need to render vision mask
    const vision = new CanvasVisionMask();
    this.addMask("vision", vision);

    // Then we need to render occlusion mask
    const occlusion = new CanvasOcclusionMask();
    this.addMask("occlusion", occlusion);

    // Then the depth mask, which need occlusion
    const depth = new CanvasDepthMask();
    this.addMask("depth", depth);
  }

  /* -------------------------------------------- */
  /*  Tear-Down                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {
    this.removeChild(this.masks);

    // Clear all masks (children of masks)
    this.masks.children.forEach(c => c.clear());

    // Then proceed normally
    await super._tearDown(options);
  }
}

/**
 * A container group which displays interface elements rendered above other canvas groups.
 * @extends {CanvasGroupMixin(PIXI.Container)}
 */
class InterfaceCanvasGroup extends CanvasGroupMixin(PIXI.Container) {

  /** @override */
  static groupName = "interface";

  /**
   * A container dedicated to the display of scrolling text.
   * @type {PIXI.Container}
   */
  #scrollingText;

  /**
   * A graphics which represent the scene outline.
   * @type {PIXI.Graphics}
   */
  #outline;

  /**
   * The interface drawings container.
   * @type {PIXI.Container}
   */
  #drawings;

  /* -------------------------------------------- */
  /*  Drawing Management                          */
  /* -------------------------------------------- */

  /**
   * Add a PrimaryGraphics to the group.
   * @param {Drawing} drawing      The Drawing being added
   * @returns {PIXI.Graphics}      The created Graphics instance
   */
  addDrawing(drawing) {
    const name = drawing.objectId;
    const shape = this.drawings.graphics.get(name) ?? this.#drawings.addChild(new PIXI.Graphics());
    shape.name = name;
    this.drawings.graphics.set(name, shape);
    return shape;
  }

  /* -------------------------------------------- */

  /**
   * Remove a PrimaryGraphics from the group.
   * @param {Drawing} drawing     The Drawing being removed
   */
  removeDrawing(drawing) {
    const name = drawing.objectId;
    if ( !this.drawings.graphics.has(name) ) return;
    const shape = this.drawings.graphics.get(name);
    if ( shape?.destroyed === false ) shape.destroy({children: true});
    this.drawings.graphics.delete(name);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    this.#drawOutline();
    this.#createInterfaceDrawingsContainer();
    this.#drawScrollingText();
    await super._draw(options);

    // Necessary so that Token#voidMesh don't earse non-interface elements
    this.filters = [new VoidFilter()];
    this.filterArea = canvas.app.screen;
  }

  /* -------------------------------------------- */

  /**
   * Draw a background outline which emphasizes what portion of the canvas is playable space and what is buffer.
   */
  #drawOutline() {
    // Create Canvas outline
    const outline = this.#outline = this.addChild(new PIXI.Graphics());

    const {scene, dimensions} = canvas;
    const displayCanvasBorder = scene.padding !== 0;
    const displaySceneOutline = !scene.background.src;
    if ( !(displayCanvasBorder || displaySceneOutline) ) return;
    if ( displayCanvasBorder ) outline.lineStyle({
      alignment: 1,
      alpha: 0.75,
      color: 0x000000,
      join: PIXI.LINE_JOIN.BEVEL,
      width: 4
    }).drawShape(dimensions.rect);
    if ( displaySceneOutline ) outline.lineStyle({
      alignment: 1,
      alpha: 0.25,
      color: 0x000000,
      join: PIXI.LINE_JOIN.BEVEL,
      width: 4
    }).drawShape(dimensions.sceneRect).endFill();
  }

  /* -------------------------------------------- */
  /*  Scrolling Text                              */
  /* -------------------------------------------- */

  /**
   * Draw the scrolling text.
   */
  #drawScrollingText() {
    this.#scrollingText = this.addChild(new PIXI.Container());

    const {width, height} = canvas.dimensions;
    this.#scrollingText.width = width;
    this.#scrollingText.height = height;
    this.#scrollingText.eventMode = "none";
    this.#scrollingText.interactiveChildren = false;
    this.#scrollingText.zIndex = CONFIG.Canvas.groups.interface.zIndexScrollingText;
  }

  /* -------------------------------------------- */

  /**
   * Create the interface drawings container.
   */
  #createInterfaceDrawingsContainer() {
    this.#drawings = this.addChild(new PIXI.Container());
    this.#drawings.sortChildren = function() {
      const children = this.children;
      for ( let i = 0, n = children.length; i < n; i++ ) children[i]._lastSortedIndex = i;
      children.sort(InterfaceCanvasGroup.#compareObjects);
      this.sortDirty = false;
    };
    this.#drawings.sortableChildren = true;
    this.#drawings.eventMode = "none";
    this.#drawings.interactiveChildren = false;
    this.#drawings.zIndex = CONFIG.Canvas.groups.interface.zIndexDrawings;
  }

  /* -------------------------------------------- */

  /**
   * The sorting function used to order objects inside the Interface Drawings Container
   * Overrides the default sorting function defined for the PIXI.Container.
   * @param {PrimaryCanvasObject|PIXI.DisplayObject} a     An object to display
   * @param {PrimaryCanvasObject|PIXI.DisplayObject} b     Some other object to display
   * @returns {number}
   */
  static #compareObjects(a, b) {
    return ((a.elevation || 0) - (b.elevation || 0))
      || ((a.sort || 0) - (b.sort || 0))
      || (a.zIndex - b.zIndex)
      || (a._lastSortedIndex - b._lastSortedIndex);
  }

  /* -------------------------------------------- */

  /**
   * Display scrolling status text originating from an origin point on the Canvas.
   * @param {Point} origin            An origin point where the text should first emerge
   * @param {string} content          The text content to display
   * @param {object} [options]        Options which customize the text animation
   * @param {number} [options.duration=2000]  The duration of the scrolling effect in milliseconds
   * @param {number} [options.distance]       The distance in pixels that the scrolling text should travel
   * @param {TEXT_ANCHOR_POINTS} [options.anchor]     The original anchor point where the text appears
   * @param {TEXT_ANCHOR_POINTS} [options.direction]  The direction in which the text scrolls
   * @param {number} [options.jitter=0]       An amount of randomization between [0, 1] applied to the initial position
   * @param {object} [options.textStyle={}]   Additional parameters of PIXI.TextStyle which are applied to the text
   * @returns {Promise<PreciseText|null>}   The created PreciseText object which is scrolling
   */
  async createScrollingText(origin, content, {duration=2000, distance, jitter=0, anchor, direction, ...textStyle}={}) {
    if ( !game.settings.get("core", "scrollingStatusText") ) return null;

    // Create text object
    const style = PreciseText.getTextStyle({anchor, ...textStyle});
    const text = this.#scrollingText.addChild(new PreciseText(content, style));
    text.visible = false;

    // Set initial coordinates
    const jx = (jitter ? (Math.random()-0.5) * jitter : 0) * text.width;
    const jy = (jitter ? (Math.random()-0.5) * jitter : 0) * text.height;
    text.position.set(origin.x + jx, origin.y + jy);

    // Configure anchor point
    text.anchor.set(...{
      [CONST.TEXT_ANCHOR_POINTS.CENTER]: [0.5, 0.5],
      [CONST.TEXT_ANCHOR_POINTS.BOTTOM]: [0.5, 0],
      [CONST.TEXT_ANCHOR_POINTS.TOP]: [0.5, 1],
      [CONST.TEXT_ANCHOR_POINTS.LEFT]: [1, 0.5],
      [CONST.TEXT_ANCHOR_POINTS.RIGHT]: [0, 0.5]
    }[anchor ?? CONST.TEXT_ANCHOR_POINTS.CENTER]);

    // Configure animation distance
    let dx = 0;
    let dy = 0;
    switch ( direction ?? CONST.TEXT_ANCHOR_POINTS.TOP ) {
      case CONST.TEXT_ANCHOR_POINTS.BOTTOM:
        dy = distance ?? (2 * text.height); break;
      case CONST.TEXT_ANCHOR_POINTS.TOP:
        dy = -1 * (distance ?? (2 * text.height)); break;
      case CONST.TEXT_ANCHOR_POINTS.LEFT:
        dx = -1 * (distance ?? (2 * text.width)); break;
      case CONST.TEXT_ANCHOR_POINTS.RIGHT:
        dx = distance ?? (2 * text.width); break;
    }

    // Fade In
    await CanvasAnimation.animate([
      {parent: text, attribute: "alpha", from: 0, to: 1.0},
      {parent: text.scale, attribute: "x", from: 0.6, to: 1.0},
      {parent: text.scale, attribute: "y", from: 0.6, to: 1.0}
    ], {
      context: this,
      duration: duration * 0.25,
      easing: CanvasAnimation.easeInOutCosine,
      ontick: () => text.visible = true
    });

    // Scroll
    const scroll = [{parent: text, attribute: "alpha", to: 0.0}];
    if ( dx !== 0 ) scroll.push({parent: text, attribute: "x", to: text.position.x + dx});
    if ( dy !== 0 ) scroll.push({parent: text, attribute: "y", to: text.position.y + dy});
    await CanvasAnimation.animate(scroll, {
      context: this,
      duration: duration * 0.75,
      easing: CanvasAnimation.easeInOutCosine
    });

    // Clean-up
    this.#scrollingText.removeChild(text);
    text.destroy();
  }
}

/**
 * A container group which is not bound to the stage world transform.
 *
 * @category - Canvas
 */
class OverlayCanvasGroup extends CanvasGroupMixin(UnboundContainer) {
  /** @override */
  static groupName = "overlay";

  /** @override */
  static tearDownChildren = false;
}


/**
 * The primary Canvas group which generally contains tangible physical objects which exist within the Scene.
 * This group is a {@link CachedContainer} which is rendered to the Scene as a {@link SpriteMesh}.
 * This allows the rendered result of the Primary Canvas Group to be affected by a {@link BaseSamplerShader}.
 * @extends {CachedContainer}
 * @mixes CanvasGroupMixin
 * @category - Canvas
 */
class PrimaryCanvasGroup extends CanvasGroupMixin(CachedContainer) {
  constructor(sprite) {
    sprite ||= new SpriteMesh(undefined, BaseSamplerShader);
    super(sprite);
    this.eventMode = "none";
    this.#createAmbienceFilter();
    this.on("childAdded", this.#onChildAdded);
    this.on("childRemoved", this.#onChildRemoved);
  }

  /**
   * Sort order to break ties on the group/layer level.
   * @enum {number}
   */
  static SORT_LAYERS = Object.freeze({
    SCENE: 0,
    TILES: 500,
    DRAWINGS: 600,
    TOKENS: 700,
    WEATHER: 1000
  });

  /** @override */
  static groupName = "primary";

  /** @override */
  static textureConfiguration = {
    scaleMode: PIXI.SCALE_MODES.NEAREST,
    format: PIXI.FORMATS.RGB,
    multisample: PIXI.MSAA_QUALITY.NONE
  };

  /** @override */
  clearColor = [0, 0, 0, 0];

  /**
   * The background color in RGB.
   * @type {[red: number, green: number, blue: number]}
   * @internal
   */
  _backgroundColor;

  /**
   * Track the set of HTMLVideoElements which are currently playing as part of this group.
   * @type {Set<SpriteMesh>}
   */
  videoMeshes = new Set();

  /**
   * Occludable objects above this elevation are faded on hover.
   * @type {number}
   */
  hoverFadeElevation = 0;

  /**
   * Allow API users to override the default elevation of the background layer.
   * This is a temporary solution until more formal support for scene levels is added in a future release.
   * @type {number}
   */
  static BACKGROUND_ELEVATION = 0;

  /* -------------------------------------------- */
  /*  Group Attributes                            */
  /* -------------------------------------------- */

  /**
   * The primary background image configured for the Scene, rendered as a SpriteMesh.
   * @type {SpriteMesh}
   */
  background;

  /**
   * The primary foreground image configured for the Scene, rendered as a SpriteMesh.
   * @type {SpriteMesh}
   */
  foreground;

  /**
   * A Quadtree which partitions and organizes primary canvas objects.
   * @type {CanvasQuadtree}
   */
  quadtree = new CanvasQuadtree();

  /**
   * The collection of PrimaryDrawingContainer objects which are rendered in the Scene.
   * @type {Collection<string, PrimaryDrawingContainer>}
   */
  drawings = new foundry.utils.Collection();

  /**
   * The collection of SpriteMesh objects which are rendered in the Scene.
   * @type {Collection<string, TokenMesh>}
   */
  tokens = new foundry.utils.Collection();

  /**
   * The collection of SpriteMesh objects which are rendered in the Scene.
   * @type {Collection<string, PrimarySpriteMesh|TileSprite>}
   */
  tiles = new foundry.utils.Collection();

  /**
   * The ambience filter which is applying post-processing effects.
   * @type {PrimaryCanvasGroupAmbienceFilter}
   * @internal
   */
  _ambienceFilter;

  /**
   * The objects that are currently hovered in reverse sort order.
   * @type {PrimaryCanvasObjec[]>}
   */
  #hoveredObjects = [];

  /**
   * Trace the tiling sprite error to avoid multiple warning.
   * FIXME: Remove when the deprecation period for the tiling sprite error is over.
   * @type {boolean}
   * @internal
   */
  #tilingSpriteError = false;

  /* -------------------------------------------- */
  /*  Group Properties                            */
  /* -------------------------------------------- */

  /**
   * Return the base HTML image or video element which provides the background texture.
   * @type {HTMLImageElement|HTMLVideoElement}
   */
  get backgroundSource() {
    if ( !this.background.texture.valid || this.background.texture === PIXI.Texture.WHITE ) return null;
    return this.background.texture.baseTexture.resource.source;
  }

  /* -------------------------------------------- */

  /**
   * Return the base HTML image or video element which provides the foreground texture.
   * @type {HTMLImageElement|HTMLVideoElement}
   */
  get foregroundSource() {
    if ( !this.foreground.texture.valid ) return null;
    return this.foreground.texture.baseTexture.resource.source;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Create the ambience filter bound to the primary group.
   */
  #createAmbienceFilter() {
    if ( this._ambienceFilter ) this._ambienceFilter.enabled = false;
    else {
      this.filters ??= [];
      const f = this._ambienceFilter = PrimaryCanvasGroupAmbienceFilter.create();
      f.enabled = false;
      this.filterArea = canvas.app.renderer.screen;
      this.filters.push(f);
    }
  }

  /* -------------------------------------------- */

  /**
   * Refresh the primary mesh.
   */
  refreshPrimarySpriteMesh() {
    const singleSource = canvas.visibility.visionModeData.source;
    const vmOptions = singleSource?.visionMode.canvas;
    const isBaseSampler = (this.sprite.shader.constructor === BaseSamplerShader);
    if ( !vmOptions && isBaseSampler ) return;

    // Update the primary sprite shader class (or reset to BaseSamplerShader)
    this.sprite.setShaderClass(vmOptions?.shader ?? BaseSamplerShader);
    this.sprite.shader.uniforms.sampler = this.renderTexture;

    // Need to update uniforms?
    if ( !vmOptions?.uniforms ) return;
    vmOptions.uniforms.linkedToDarknessLevel = singleSource?.visionMode.vision.darkness.adaptive;
    vmOptions.uniforms.darknessLevel = canvas.environment.darknessLevel;
    vmOptions.uniforms.darknessLevelTexture = canvas.effects.illumination.renderTexture;
    vmOptions.uniforms.screenDimensions = canvas.screenDimensions;

    // Assigning color from source if any
    vmOptions.uniforms.tint = singleSource?.visionModeOverrides.colorRGB
      ?? this.sprite.shader.constructor.defaultUniforms.tint;

    // Updating uniforms in the primary sprite shader
    for ( const [uniform, value] of Object.entries(vmOptions?.uniforms ?? {}) ) {
      if ( uniform in this.sprite.shader.uniforms ) this.sprite.shader.uniforms[uniform] = value;
    }
  }

  /* -------------------------------------------- */

  /**
   * Update this group. Calculates the canvas transform and bounds of all its children and updates the quadtree.
   */
  update() {
    if ( this.sortDirty ) this.sortChildren();
    const children = this.children;
    for ( let i = 0, n = children.length; i < n; i++ ) {
      children[i].updateCanvasTransform?.();
    }
    canvas.masks.depth._update();
    if ( !CONFIG.debug.canvas.primary.bounds ) return;
    const dbg = canvas.controls.debug.clear().lineStyle(5, 0x30FF00);
    for ( const child of this.children ) {
      if ( child.canvasBounds ) dbg.drawShape(child.canvasBounds);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    this.#drawBackground();
    this.#drawForeground();
    this.#drawPadding();
    this.hoverFadeElevation = 0;
    await super._draw(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _render(renderer) {
    const [r, g, b] = this._backgroundColor;
    renderer.framebuffer.clear(r, g, b, 1, PIXI.BUFFER_BITS.COLOR);
    super._render(renderer);
  }

  /* -------------------------------------------- */

  /**
   * Draw the Scene background image.
   */
  #drawBackground() {
    const bg = this.background = this.addChild(new PrimarySpriteMesh({name: "background", object: this}));
    bg.elevation = this.constructor.BACKGROUND_ELEVATION;
    const bgTextureSrc = canvas.sceneTextures.background ?? canvas.scene.background.src;
    const bgTexture = bgTextureSrc instanceof PIXI.Texture ? bgTextureSrc : getTexture(bgTextureSrc);
    this.#drawSceneMesh(bg, bgTexture);
  }

  /* -------------------------------------------- */

  /**
   * Draw the Scene foreground image.
   */
  #drawForeground() {
    const fg = this.foreground = this.addChild(new PrimarySpriteMesh({name: "foreground", object: this}));
    fg.elevation = canvas.scene.foregroundElevation;
    const fgTextureSrc = canvas.sceneTextures.foreground ?? canvas.scene.foreground;
    const fgTexture = fgTextureSrc instanceof PIXI.Texture ? fgTextureSrc : getTexture(fgTextureSrc);

    // Compare dimensions with background texture and draw the mesh
    const bg = this.background.texture;
    if ( fgTexture && bg && ((fgTexture.width !== bg.width) || (fgTexture.height !== bg.height)) ) {
      ui.notifications.warn("WARNING.ForegroundDimensionsMismatch", {localize: true});
    }
    this.#drawSceneMesh(fg, fgTexture);
  }

  /* -------------------------------------------- */

  /**
   * Draw a PrimarySpriteMesh that fills the entire Scene rectangle.
   * @param {PrimarySpriteMesh} mesh        The target PrimarySpriteMesh
   * @param {PIXI.Texture|null} texture     The loaded Texture or null
   */
  #drawSceneMesh(mesh, texture) {
    const d = canvas.dimensions;
    mesh.texture = texture ?? PIXI.Texture.EMPTY;
    mesh.textureAlphaThreshold = 0.75;
    mesh.occludedAlpha = 0.5;
    mesh.visible = mesh.texture !== PIXI.Texture.EMPTY;
    mesh.position.set(d.sceneX, d.sceneY);
    mesh.width = d.sceneWidth;
    mesh.height = d.sceneHeight;
    mesh.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.SCENE;
    mesh.zIndex = -Infinity;
    mesh.hoverFade = false;

    // Manage video playback
    const video = game.video.getVideoSource(mesh);
    if ( video ) {
      this.videoMeshes.add(mesh);
      game.video.play(video, {volume: game.settings.get("core", "globalAmbientVolume")});
    }
  }

  /* -------------------------------------------- */

  /**
   * Draw the Scene padding.
   */
  #drawPadding() {
    const d = canvas.dimensions;
    const g = this.addChild(new PIXI.LegacyGraphics());
    g.beginFill(0x000000, 0.025)
      .drawShape(d.rect)
      .beginHole()
      .drawShape(d.sceneRect)
      .endHole()
      .endFill();
    g.elevation = -Infinity;
    g.sort = -Infinity;
  }

  /* -------------------------------------------- */
  /*  Tear-Down                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _tearDown(options) {

    // Stop video playback
    for ( const mesh of this.videoMeshes ) game.video.stop(mesh.sourceElement);

    await super._tearDown(options);

    // Clear collections
    this.videoMeshes.clear();
    this.tokens.clear();
    this.tiles.clear();

    // Clear the quadtree
    this.quadtree.clear();

    // Reset the tiling sprite tracker
    this.#tilingSpriteError = false;
  }

  /* -------------------------------------------- */
  /*  Token Management                            */
  /* -------------------------------------------- */

  /**
   * Draw the SpriteMesh for a specific Token object.
   * @param {Token} token           The Token being added
   * @returns {PrimarySpriteMesh}   The added PrimarySpriteMesh
   */
  addToken(token) {
    const name = token.objectId;

    // Create the token mesh
    const mesh = this.tokens.get(name) ?? this.addChild(new PrimarySpriteMesh({name, object: token}));
    mesh.texture = token.texture ?? PIXI.Texture.EMPTY;
    this.tokens.set(name, mesh);
    if ( mesh.isVideo ) this.videoMeshes.add(mesh);
    return mesh;
  }

  /* -------------------------------------------- */

  /**
   * Remove a TokenMesh from the group.
   * @param {Token} token     The Token being removed
   */
  removeToken(token) {
    const name = token.objectId;
    const mesh = this.tokens.get(name);
    if ( mesh?.destroyed === false ) mesh.destroy({children: true});
    this.tokens.delete(name);
    this.videoMeshes.delete(mesh);
  }

  /* -------------------------------------------- */
  /*  Tile Management                             */
  /* -------------------------------------------- */

  /**
   * Draw the SpriteMesh for a specific Token object.
   * @param {Tile} tile                        The Tile being added
   * @returns {PrimarySpriteMesh}              The added PrimarySpriteMesh
   */
  addTile(tile) {
    /** @deprecated since v12 */
    if ( !this.#tilingSpriteError && tile.document.getFlag("core", "isTilingSprite") ) {
      this.#tilingSpriteError = true;
      ui.notifications.warn("WARNING.TilingSpriteDeprecation", {localize: true, permanent: true});
      const msg = "Tiling Sprites are deprecated without replacement.";
      foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    }

    const name = tile.objectId;
    let mesh = this.tiles.get(name) ?? this.addChild(new PrimarySpriteMesh({name, object: tile}));
    mesh.texture = tile.texture ?? PIXI.Texture.EMPTY;
    this.tiles.set(name, mesh);
    if ( mesh.isVideo ) this.videoMeshes.add(mesh);
    return mesh;
  }

  /* -------------------------------------------- */

  /**
   * Remove a TokenMesh from the group.
   * @param {Tile} tile     The Tile being removed
   */
  removeTile(tile) {
    const name = tile.objectId;
    const mesh = this.tiles.get(name);
    if ( mesh?.destroyed === false ) mesh.destroy({children: true});
    this.tiles.delete(name);
    this.videoMeshes.delete(mesh);
  }

  /* -------------------------------------------- */
  /*  Drawing Management                          */
  /* -------------------------------------------- */

  /**
   * Add a PrimaryGraphics to the group.
   * @param {Drawing} drawing      The Drawing being added
   * @returns {PrimaryGraphics}    The created PrimaryGraphics instance
   */
  addDrawing(drawing) {
    const name = drawing.objectId;
    const shape = this.drawings.get(name) ?? this.addChild(new PrimaryGraphics({name, object: drawing}));
    this.drawings.set(name, shape);
    return shape;
  }

  /* -------------------------------------------- */

  /**
   * Remove a PrimaryGraphics from the group.
   * @param {Drawing} drawing     The Drawing being removed
   */
  removeDrawing(drawing) {
    const name = drawing.objectId;
    if ( !this.drawings.has(name) ) return;
    const shape = this.drawings.get(name);
    if ( shape?.destroyed === false ) shape.destroy({children: true});
    this.drawings.delete(name);
  }

  /* -------------------------------------------- */

  /**
   * Override the default PIXI.Container behavior for how objects in this container are sorted.
   * @override
   */
  sortChildren() {
    const children = this.children;
    for ( let i = 0, n = children.length; i < n; i++ ) children[i]._lastSortedIndex = i;
    children.sort(PrimaryCanvasGroup.#compareObjects);
    this.sortDirty = false;
  }

  /* -------------------------------------------- */

  /**
   * The sorting function used to order objects inside the Primary Canvas Group.
   * Overrides the default sorting function defined for the PIXI.Container.
   * Sort Tokens PCO above other objects except WeatherEffects, then Drawings PCO, all else held equal.
   * @param {PrimaryCanvasObject|PIXI.DisplayObject} a     An object to display
   * @param {PrimaryCanvasObject|PIXI.DisplayObject} b     Some other object to display
   * @returns {number}
   */
  static #compareObjects(a, b) {
    return ((a.elevation || 0) - (b.elevation || 0))
      || ((a.sortLayer || 0) - (b.sortLayer || 0))
      || ((a.sort || 0) - (b.sort || 0))
      || (a.zIndex - b.zIndex)
      || (a._lastSortedIndex - b._lastSortedIndex);
  }

  /* -------------------------------------------- */
  /*  PIXI Events                                 */
  /* -------------------------------------------- */

  /**
   * Called when a child is added.
   * @param {PIXI.DisplayObject} child
   */
  #onChildAdded(child) {
    if ( child.shouldRenderDepth ) canvas.masks.depth._elevationDirty = true;
  }

  /* -------------------------------------------- */

  /**
   * Called when a child is removed.
   * @param {PIXI.DisplayObject} child
   */
  #onChildRemoved(child) {
    if ( child.shouldRenderDepth ) canvas.masks.depth._elevationDirty = true;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle mousemove events on the primary group to update the hovered state of its children.
   * @internal
   */
  _onMouseMove() {
    const time = canvas.app.ticker.lastTime;

    // Unset the hovered state of the hovered PCOs
    for ( const object of this.#hoveredObjects ) {
      if ( !object._hoverFadeState.hovered ) continue;
      object._hoverFadeState.hovered = false;
      object._hoverFadeState.hoveredTime = time;
    }

    this.#updateHoveredObjects();

    // Set the hovered state of the hovered PCOs
    for ( const object of this.#hoveredObjects ) {
      if ( !object.hoverFade || !(object.elevation > this.hoverFadeElevation) ) break;
      object._hoverFadeState.hovered = true;
      object._hoverFadeState.hoveredTime = time;
    }
  }

  /* -------------------------------------------- */

  /**
   * Update the hovered objects. Returns the hovered objects.
   */
  #updateHoveredObjects() {
    this.#hoveredObjects.length = 0;

    // Get all PCOs that contain the mouse position
    const position = canvas.mousePosition;
    const collisionTest = ({t}) => t.visible && t.renderable
      && t._hoverFadeState && t.containsCanvasPoint(position);
    for ( const object of canvas.primary.quadtree.getObjects(
      new PIXI.Rectangle(position.x, position.y, 0, 0), {collisionTest}
    )) {
      this.#hoveredObjects.push(object);
    }

    // Sort the hovered PCOs in reverse primary order
    this.#hoveredObjects.sort((a, b) => PrimaryCanvasGroup.#compareObjects(b, a));

    // Discard hit objects below the hovered placeable
    const hoveredPlaceable = canvas.activeLayer?.hover;
    if ( hoveredPlaceable ) {
      let elevation = 0;
      let sortLayer = Infinity;
      let sort = Infinity;
      let zIndex = Infinity;
      if ( (hoveredPlaceable instanceof Token) || (hoveredPlaceable instanceof Tile) ) {
        const mesh = hoveredPlaceable.mesh;
        if ( mesh ) {
          elevation = mesh.elevation;
          sortLayer = mesh.sortLayer;
          sort = mesh.sort;
          zIndex = mesh.zIndex;
        }
      } else if ( hoveredPlaceable instanceof Drawing ) {
        const shape = hoveredPlaceable.shape;
        if ( shape ) {
          elevation = shape.elevation;
          sortLayer = shape.sortLayer;
          sort = shape.sort;
          zIndex = shape.zIndex;
        }
      } else if ( hoveredPlaceable.document.schema.has("elevation") ) {
        elevation = hoveredPlaceable.document.elevation;
      }
      const threshold = {elevation, sortLayer, sort, zIndex, _lastSortedIndex: Infinity};
      while ( this.#hoveredObjects.length
        && PrimaryCanvasGroup.#compareObjects(this.#hoveredObjects.at(-1), threshold) <= 0 ) {
        this.#hoveredObjects.pop();
      }
    }
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  mapElevationToDepth(elevation) {
    const msg = "PrimaryCanvasGroup#mapElevationAlpha is deprecated. "
      + "Use canvas.masks.depth.mapElevation(elevation) instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return canvas.masks.depth.mapElevation(elevation);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  mapElevationAlpha(elevation) {
    const msg = "PrimaryCanvasGroup#mapElevationAlpha is deprecated. "
      + "Use canvas.masks.depth.mapElevation(elevation) instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return canvas.masks.depth.mapElevation(elevation);
  }
}

/**
 * A container group which contains the environment canvas group and the interface canvas group.
 *
 * @category - Canvas
 */
class RenderedCanvasGroup extends CanvasGroupMixin(PIXI.Container) {
  /** @override */
  static groupName = "rendered";

  /** @override */
  static tearDownChildren = false;
}


/**
 * @typedef {Map<number,PolygonVertex>} VertexMap
 */

/**
 * @typedef {Set<Edge>} EdgeSet
 */

/**
 * @typedef {Ray} PolygonRay
 * @property {CollisionResult} result
 */

/**
 * A PointSourcePolygon implementation that uses CCW (counter-clockwise) geometry orientation.
 * Sweep around the origin, accumulating collision points based on the set of active walls.
 * This algorithm was created with valuable contributions from https://github.com/caewok
 *
 * @extends PointSourcePolygon
 */
class ClockwiseSweepPolygon extends PointSourcePolygon {

  /**
   * A mapping of vertices which define potential collision points
   * @type {VertexMap}
   */
  vertices = new Map();

  /**
   * The set of edges which define potential boundaries of the polygon
   * @type {EdgeSet}
   */
  edges = new Set();

  /**
   * A collection of rays which are fired at vertices
   * @type {PolygonRay[]}
   */
  rays = [];

  /**
   * The squared maximum distance of a ray that is needed for this Scene.
   * @type {number}
   */
  #rayDistance2;

  /* -------------------------------------------- */
  /*  Initialization                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  initialize(origin, config) {
    super.initialize(origin, config);
    this.#rayDistance2 = Math.pow(canvas.dimensions.maxR, 2);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  clone() {
    const poly = super.clone();
    for ( const attr of ["vertices", "edges", "rays", "#rayDistance2"] ) { // Shallow clone only
      poly[attr] = this[attr];
    }
    return poly;
  }

  /* -------------------------------------------- */
  /*  Computation                                 */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _compute() {

    // Clear prior data
    this.points = [];
    this.rays = [];
    this.vertices.clear();
    this.edges.clear();

    // Step 1 - Identify candidate edges
    this._identifyEdges();

    // Step 2 - Construct vertex mapping
    this._identifyVertices();

    // Step 3 - Radial sweep over endpoints
    this._executeSweep();

    // Step 4 - Constrain with boundary shapes
    this._constrainBoundaryShapes();
  }

  /* -------------------------------------------- */
  /*  Edge Configuration                          */
  /* -------------------------------------------- */

  /**
   * Get the super-set of walls which could potentially apply to this polygon.
   * Define a custom collision test used by the Quadtree to obtain candidate Walls.
   * @protected
   */
  _identifyEdges() {
    const bounds = this.config.boundingBox = this._defineBoundingBox();
    const edgeTypes = this._determineEdgeTypes();
    for ( const edge of canvas.edges.values() ) {
      if ( this._testEdgeInclusion(edge, edgeTypes, bounds) ) {
        this.edges.add(edge.clone());
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Determine the edge types and their manner of inclusion for this polygon instance.
   * @returns {Record<EdgeTypes, 0|1|2>}
   * @protected
   */
  _determineEdgeTypes() {
    const {type, useInnerBounds, includeDarkness} = this.config;
    const edgeTypes = {};
    if ( type !== "universal" ) edgeTypes.wall = 1;
    if ( includeDarkness ) edgeTypes.darkness = 1;
    if ( useInnerBounds && canvas.scene.padding ) edgeTypes.innerBounds = 2;
    else edgeTypes.outerBounds = 2;
    return edgeTypes;
  }

  /* -------------------------------------------- */

  /**
   * Test whether a wall should be included in the computed polygon for a given origin and type
   * @param {Edge} edge                     The Edge being considered
   * @param {Record<EdgeTypes, 0|1|2>} edgeTypes Which types of edges are being used? 0=no, 1=maybe, 2=always
   * @param {PIXI.Rectangle} bounds         The overall bounding box
   * @returns {boolean}                     Should the edge be included?
   * @protected
   */
  _testEdgeInclusion(edge, edgeTypes, bounds) {
    const { type, boundaryShapes, useThreshold, wallDirectionMode, externalRadius } = this.config;

    // Only include edges of the appropriate type
    const m = edgeTypes[edge.type];
    if ( !m ) return false;
    if ( m === 2 ) return true;

    // Test for inclusion in the overall bounding box
    if ( !bounds.lineSegmentIntersects(edge.a, edge.b, { inside: true }) ) return false;

    // Specific boundary shapes may impose additional requirements
    for ( const shape of boundaryShapes ) {
      if ( shape._includeEdge && !shape._includeEdge(edge.a, edge.b) ) return false;
    }

    // Ignore edges which do not block this polygon type
    if ( edge[type] === CONST.WALL_SENSE_TYPES.NONE ) return false;

    // Ignore edges which are collinear with the origin
    const side = edge.orientPoint(this.origin);
    if ( !side ) return false;

    // Ignore one-directional walls which are facing away from the origin
    const wdm = PointSourcePolygon.WALL_DIRECTION_MODES;
    if ( edge.direction && (wallDirectionMode !== wdm.BOTH) ) {
      if ( (wallDirectionMode === wdm.NORMAL) === (side === edge.direction) ) return false;
    }

    // Ignore threshold walls which do not satisfy their required proximity
    if ( useThreshold ) return !edge.applyThreshold(type, this.origin, externalRadius);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Compute the aggregate bounding box which is the intersection of all boundary shapes.
   * Round and pad the resulting rectangle by 1 pixel to ensure it always contains the origin.
   * @returns {PIXI.Rectangle}
   * @protected
   */
  _defineBoundingBox() {
    let b = this.config.useInnerBounds ? canvas.dimensions.sceneRect : canvas.dimensions.rect;
    for ( const shape of this.config.boundaryShapes ) {
      b = b.intersection(shape.getBounds());
    }
    return new PIXI.Rectangle(b.x, b.y, b.width, b.height).normalize().ceil().pad(1);
  }

  /* -------------------------------------------- */
  /*  Vertex Identification                       */
  /* -------------------------------------------- */

  /**
   * Consolidate all vertices from identified edges and register them as part of the vertex mapping.
   * @protected
   */
  _identifyVertices() {
    const edgeMap = new Map();
    for ( let edge of this.edges ) {
      edgeMap.set(edge.id, edge);

      // Create or reference vertex A
      const ak = foundry.canvas.edges.PolygonVertex.getKey(edge.a.x, edge.a.y);
      if ( this.vertices.has(ak) ) edge.vertexA = this.vertices.get(ak);
      else {
        edge.vertexA = new foundry.canvas.edges.PolygonVertex(edge.a.x, edge.a.y);
        this.vertices.set(ak, edge.vertexA);
      }

      // Create or reference vertex B
      const bk = foundry.canvas.edges.PolygonVertex.getKey(edge.b.x, edge.b.y);
      if ( this.vertices.has(bk) ) edge.vertexB = this.vertices.get(bk);
      else {
        edge.vertexB = new foundry.canvas.edges.PolygonVertex(edge.b.x, edge.b.y);
        this.vertices.set(bk, edge.vertexB);
      }

      // Learn edge orientation with respect to the origin and ensure B is clockwise of A
      const o = foundry.utils.orient2dFast(this.origin, edge.vertexA, edge.vertexB);
      if ( o > 0 ) Object.assign(edge, {vertexA: edge.vertexB, vertexB: edge.vertexA}); // Reverse vertices
      if ( o !== 0 ) { // Attach non-collinear edges
        edge.vertexA.attachEdge(edge, -1, this.config.type);
        edge.vertexB.attachEdge(edge, 1, this.config.type);
      }
    }

    // Add edge intersections
    this._identifyIntersections(edgeMap);
  }

  /* -------------------------------------------- */

  /**
   * Add additional vertices for intersections between edges.
   * @param {Map<string, Edge>} edgeMap
   * @protected
   */
  _identifyIntersections(edgeMap) {
    const processed = new Set();
    for ( let edge of this.edges ) {
      for ( const x of edge.intersections ) {

        // Is the intersected edge also included in the polygon?
        const other = edgeMap.get(x.edge.id);
        if ( !other || processed.has(other) ) continue;
        const i = x.intersection;

        // Register the intersection point as a vertex
        const vk = foundry.canvas.edges.PolygonVertex.getKey(Math.round(i.x), Math.round(i.y));
        let v = this.vertices.get(vk);
        if ( !v ) {
          v = new foundry.canvas.edges.PolygonVertex(i.x, i.y);
          v._intersectionCoordinates = i;
          this.vertices.set(vk, v);
        }

        // Attach edges to the intersection vertex
        // Due to rounding, it is possible for an edge to be completely cw or ccw or only one of the two
        // We know from _identifyVertices that vertex B is clockwise of vertex A for every edge.
        // It is important that we use the true intersection coordinates (i) for this orientation test.
        if ( !v.edges.has(edge) ) {
          const dir = foundry.utils.orient2dFast(this.origin, edge.vertexB, i) < 0 ? 1    // Edge is fully CCW of v
            : (foundry.utils.orient2dFast(this.origin, edge.vertexA, i) > 0 ? -1 : 0);    // Edge is fully CW of v
          v.attachEdge(edge, dir, this.config.type);
        }
        if ( !v.edges.has(other) ) {
          const dir = foundry.utils.orient2dFast(this.origin, other.vertexB, i) < 0 ? 1   // Other is fully CCW of v
            : (foundry.utils.orient2dFast(this.origin, other.vertexA, i) > 0 ? -1 : 0);   // Other is fully CW of v
          v.attachEdge(other, dir, this.config.type);
        }
      }
      processed.add(edge);
    }
  }

  /* -------------------------------------------- */
  /*  Radial Sweep                                */
  /* -------------------------------------------- */

  /**
   * Execute the sweep over wall vertices
   * @private
   */
  _executeSweep() {

    // Initialize the set of active walls
    const activeEdges = this._initializeActiveEdges();

    // Sort vertices from clockwise to counter-clockwise and begin the sweep
    const vertices = this._sortVertices();

    // Iterate through the vertices, adding polygon points
    let i = 1;
    for ( const vertex of vertices ) {
      if ( vertex._visited ) continue;
      vertex._index = i++;
      this.#updateActiveEdges(vertex, activeEdges);

      // Include collinear vertices in this iteration of the sweep, treating their edges as active also
      const hasCollinear = vertex.collinearVertices.size > 0;
      if ( hasCollinear ) {
        this.#includeCollinearVertices(vertex, vertex.collinearVertices);
        for ( const cv of vertex.collinearVertices ) {
          cv._index = i++;
          this.#updateActiveEdges(cv, activeEdges);
        }
      }

      // Determine the result of the sweep for the given vertex
      this._determineSweepResult(vertex, activeEdges, hasCollinear);
    }
  }

  /* -------------------------------------------- */

  /**
   * Include collinear vertices until they have all been added.
   * Do not include the original vertex in the set.
   * @param {PolygonVertex} vertex  The current vertex
   * @param {PolygonVertexSet} collinearVertices
   */
  #includeCollinearVertices(vertex, collinearVertices) {
    for ( const cv of collinearVertices) {
      for ( const ccv of cv.collinearVertices ) {
        collinearVertices.add(ccv);
      }
    }
    collinearVertices.delete(vertex);
  }

  /* -------------------------------------------- */

  /**
   * Update active edges at a given vertex
   * Remove counter-clockwise edges which have now concluded.
   * Add clockwise edges which are ongoing or beginning.
   * @param {PolygonVertex} vertex   The current vertex
   * @param {EdgeSet} activeEdges    A set of currently active edges
   */
  #updateActiveEdges(vertex, activeEdges) {
    for ( const ccw of vertex.ccwEdges ) {
      if ( !vertex.cwEdges.has(ccw) ) activeEdges.delete(ccw);
    }
    for ( const cw of vertex.cwEdges ) {
      if ( cw.vertexA._visited && cw.vertexB._visited ) continue; // Safeguard in case we have already visited the edge
      activeEdges.add(cw);
    }
    vertex._visited = true; // Record that we have already visited this vertex
  }

  /* -------------------------------------------- */

  /**
   * Determine the initial set of active edges as those which intersect with the initial ray
   * @returns {EdgeSet}             A set of initially active edges
   * @private
   */
  _initializeActiveEdges() {
    const initial = {x: Math.round(this.origin.x - this.#rayDistance2), y: this.origin.y};
    const edges = new Set();
    for ( let edge of this.edges ) {
      const x = foundry.utils.lineSegmentIntersects(this.origin, initial, edge.vertexA, edge.vertexB);
      if ( x ) edges.add(edge);
    }
    return edges;
  }

  /* -------------------------------------------- */

  /**
   * Sort vertices clockwise from the initial ray (due west).
   * @returns {PolygonVertex[]}             The array of sorted vertices
   * @private
   */
  _sortVertices() {
    if ( !this.vertices.size ) return [];
    let vertices = Array.from(this.vertices.values());
    const o = this.origin;

    // Sort vertices
    vertices.sort((a, b) => {

      // Use true intersection coordinates if they are defined
      let pA = a._intersectionCoordinates || a;
      let pB = b._intersectionCoordinates || b;

      // Sort by hemisphere
      const ya = pA.y > o.y ? 1 : -1;
      const yb = pB.y > o.y ? 1 : -1;
      if ( ya !== yb ) return ya;       // Sort N, S

      // Sort by quadrant
      const qa = pA.x < o.x ? -1 : 1;
      const qb = pB.x < o.x ? -1 : 1;
      if ( qa !== qb ) {                // Sort NW, NE, SE, SW
        if ( ya === -1 ) return qa;
        else return -qa;
      }

      // Sort clockwise within quadrant
      const orientation = foundry.utils.orient2dFast(o, pA, pB);
      if ( orientation !== 0 ) return orientation;

      // At this point, we know points are collinear; track for later processing.
      a.collinearVertices.add(b);
      b.collinearVertices.add(a);

      // Otherwise, sort closer points first
      a._d2 ||= Math.pow(pA.x - o.x, 2) + Math.pow(pA.y - o.y, 2);
      b._d2 ||= Math.pow(pB.x - o.x, 2) + Math.pow(pB.y - o.y, 2);
      return a._d2 - b._d2;
    });
    return vertices;
  }

  /* -------------------------------------------- */

  /**
   * Test whether a target vertex is behind some closer active edge.
   * If the vertex is to the left of the edge, is must be behind the edge relative to origin.
   * If the vertex is collinear with the edge, it should be considered "behind" and ignored.
   * We know edge.vertexA is ccw to edge.vertexB because of the logic in _identifyVertices.
   * @param {PolygonVertex} vertex      The target vertex
   * @param {EdgeSet} activeEdges       The set of active edges
   * @returns {{isBehind: boolean, wasLimited: boolean}} Is the target vertex behind some closer edge?
   * @private
   */
  _isVertexBehindActiveEdges(vertex, activeEdges) {
    let wasLimited = false;
    for ( let edge of activeEdges ) {
      if ( vertex.edges.has(edge) ) continue;
      if ( foundry.utils.orient2dFast(edge.vertexA, edge.vertexB, vertex) > 0 ) {
        if ( ( edge.isLimited(this.config.type) ) && !wasLimited ) wasLimited = true;
        else return {isBehind: true, wasLimited};
      }
    }
    return {isBehind: false, wasLimited};
  }

  /* -------------------------------------------- */

  /**
   * Determine the result for the sweep at a given vertex
   * @param {PolygonVertex} vertex      The target vertex
   * @param {EdgeSet} activeEdges       The set of active edges
   * @param {boolean} hasCollinear      Are there collinear vertices behind the target vertex?
   * @private
   */
  _determineSweepResult(vertex, activeEdges, hasCollinear=false) {

    // Determine whether the target vertex is behind some other active edge
    const {isBehind, wasLimited} = this._isVertexBehindActiveEdges(vertex, activeEdges);

    // Case 1 - Some vertices can be ignored because they are behind other active edges
    if ( isBehind ) return;

    // Construct the CollisionResult object
    const result = new foundry.canvas.edges.CollisionResult({
      target: vertex,
      cwEdges: vertex.cwEdges,
      ccwEdges: vertex.ccwEdges,
      isLimited: vertex.isLimited,
      isBehind,
      wasLimited
    });

    // Case 2 - No counter-clockwise edge, so begin a new edge
    // Note: activeEdges always contain the vertex edge, so never empty
    const nccw = vertex.ccwEdges.size;
    if ( !nccw ) {
      this._switchEdge(result, activeEdges);
      result.collisions.forEach(pt => this.addPoint(pt));
      return;
    }

    // Case 3 - Limited edges in both directions
    // We can only guarantee this case if we don't have collinear endpoints
    const ccwLimited = !result.wasLimited && vertex.isLimitingCCW;
    const cwLimited = !result.wasLimited && vertex.isLimitingCW;
    if ( !hasCollinear && cwLimited && ccwLimited ) return;

    // Case 4 - Non-limited edges in both directions
    if ( !ccwLimited && !cwLimited && nccw && vertex.cwEdges.size ) {
      result.collisions.push(result.target);
      this.addPoint(result.target);
      return;
    }

    // Case 5 - Otherwise switching edges or edge types
    this._switchEdge(result, activeEdges);
    result.collisions.forEach(pt => this.addPoint(pt));
  }

  /* -------------------------------------------- */

  /**
   * Switch to a new active edge.
   * Moving from the origin, a collision that first blocks a side must be stored as a polygon point.
   * Subsequent collisions blocking that side are ignored. Once both sides are blocked, we are done.
   *
   * Collisions that limit a side will block if that side was previously limited.
   *
   * If neither side is blocked and the ray internally collides with a non-limited edge, n skip without adding polygon
   * endpoints. Sight is unaffected before this edge, and the internal collision can be ignored.
   * @private
   *
   * @param {CollisionResult} result    The pending collision result
   * @param {EdgeSet} activeEdges       The set of currently active edges
   */
  _switchEdge(result, activeEdges) {
    const origin = this.origin;

    // Construct the ray from the origin
    const ray = Ray.towardsPointSquared(origin, result.target, this.#rayDistance2);
    ray.result = result;
    this.rays.push(ray); // For visualization and debugging

    // Create a sorted array of collisions containing the target vertex, other collinear vertices, and collision points
    const vertices = [result.target, ...result.target.collinearVertices];
    const keys = new Set();
    for ( const v of vertices ) {
      keys.add(v.key);
      v._d2 ??= Math.pow(v.x - origin.x, 2) + Math.pow(v.y - origin.y, 2);
    }
    this.#addInternalEdgeCollisions(vertices, keys, ray, activeEdges);
    vertices.sort((a, b) => a._d2 - b._d2);

    // As we iterate over intersection points we will define the insertion method
    let insert = undefined;
    const c = result.collisions;
    for ( const x of vertices ) {

      if ( x.isInternal ) {  // Handle internal collisions
        // If neither side yet blocked and this is a non-limited edge, return
        if ( !result.blockedCW && !result.blockedCCW && !x.isLimited ) return;

        // Assume any edge is either limited or normal, so if not limited, must block. If already limited, must block
        result.blockedCW ||= !x.isLimited || result.limitedCW;
        result.blockedCCW ||= !x.isLimited || result.limitedCCW;
        result.limitedCW = true;
        result.limitedCCW = true;

      } else { // Handle true endpoints
        result.blockedCW ||= (result.limitedCW && x.isLimitingCW) || x.isBlockingCW;
        result.blockedCCW ||= (result.limitedCCW && x.isLimitingCCW) || x.isBlockingCCW;
        result.limitedCW ||= x.isLimitingCW;
        result.limitedCCW ||= x.isLimitingCCW;
      }

      // Define the insertion method and record a collision point
      if ( result.blockedCW ) {
        insert ||= c.unshift;
        if ( !result.blockedCWPrev ) insert.call(c, x);
      }
      if ( result.blockedCCW ) {
        insert ||= c.push;
        if ( !result.blockedCCWPrev ) insert.call(c, x);
      }

      // Update blocking flags
      if ( result.blockedCW && result.blockedCCW ) return;
      result.blockedCWPrev ||= result.blockedCW;
      result.blockedCCWPrev ||= result.blockedCCW;
    }
  }

  /* -------------------------------------------- */

  /**
   * Identify the collision points between an emitted Ray and a set of active edges.
   * @param {PolygonVertex[]} vertices      Active vertices
   * @param {Set<number>} keys              Active vertex keys
   * @param {PolygonRay} ray                The candidate ray to test
   * @param {EdgeSet} activeEdges           The set of edges to check for collisions against the ray
   */
  #addInternalEdgeCollisions(vertices, keys, ray, activeEdges) {
    for ( const edge of activeEdges ) {
      if ( keys.has(edge.vertexA.key) || keys.has(edge.vertexB.key) ) continue;
      const x = foundry.utils.lineLineIntersection(ray.A, ray.B, edge.vertexA, edge.vertexB);
      if ( !x ) continue;
      const c = foundry.canvas.edges.PolygonVertex.fromPoint(x);
      c.attachEdge(edge, 0, this.config.type);
      c.isInternal = true;
      c._d2 = Math.pow(x.x - ray.A.x, 2) + Math.pow(x.y - ray.A.y, 2);
      vertices.push(c);
    }
  }

  /* -------------------------------------------- */
  /*  Collision Testing                           */
  /* -------------------------------------------- */

  /** @override */
  _testCollision(ray, mode) {
    const {debug, type} = this.config;

    // Identify candidate edges
    this._identifyEdges();

    // Identify collision points
    let collisions = new Map();
    for ( const edge of this.edges ) {
      const x = foundry.utils.lineSegmentIntersection(this.origin, ray.B, edge.a, edge.b);
      if ( !x || (x.t0 <= 0) ) continue;
      if ( (mode === "any") && (!edge.isLimited(type) || collisions.size) ) return true;
      let c = foundry.canvas.edges.PolygonVertex.fromPoint(x, {distance: x.t0});
      if ( collisions.has(c.key) ) c = collisions.get(c.key);
      else collisions.set(c.key, c);
      c.attachEdge(edge, 0, type);
    }
    if ( mode === "any" ) return false;

    // Sort collisions
    collisions = Array.from(collisions.values()).sort((a, b) => a._distance - b._distance);
    if ( collisions[0]?.isLimited ) collisions.shift();

    // Visualize result
    if ( debug ) this._visualizeCollision(ray, collisions);

    // Return collision result
    if ( mode === "all" ) return collisions;
    else return collisions[0] || null;
  }

  /* -------------------------------------------- */
  /*  Visualization                               */
  /* -------------------------------------------- */

  /** @override */
  visualize() {
    let dg = canvas.controls.debug;
    dg.clear();

    // Text debugging
    if ( !canvas.controls.debug.debugText ) {
      canvas.controls.debug.debugText = canvas.controls.addChild(new PIXI.Container());
    }
    const text = canvas.controls.debug.debugText;
    text.removeChildren().forEach(c => c.destroy({children: true}));

    // Define limitation colors
    const limitColors = {
      [CONST.WALL_SENSE_TYPES.NONE]: 0x77E7E8,
      [CONST.WALL_SENSE_TYPES.NORMAL]: 0xFFFFBB,
      [CONST.WALL_SENSE_TYPES.LIMITED]: 0x81B90C,
      [CONST.WALL_SENSE_TYPES.PROXIMITY]: 0xFFFFBB,
      [CONST.WALL_SENSE_TYPES.DISTANCE]: 0xFFFFBB
    };

    // Draw boundary shapes
    for ( const constraint of this.config.boundaryShapes ) {
      dg.lineStyle(2, 0xFF4444, 1.0).beginFill(0xFF4444, 0.10).drawShape(constraint).endFill();
    }

    // Draw the final polygon shape
    dg.beginFill(0x00AAFF, 0.25).drawShape(this).endFill();

    // Draw candidate edges
    for ( let edge of this.edges ) {
      const c = limitColors[edge[this.config.type]];
      dg.lineStyle(4, c).moveTo(edge.a.x, edge.a.y).lineTo(edge.b.x, edge.b.y);
    }

    // Draw vertices
    for ( let vertex of this.vertices.values() ) {
      const r = vertex.restriction;
      if ( r ) dg.lineStyle(1, 0x000000).beginFill(limitColors[r]).drawCircle(vertex.x, vertex.y, 8).endFill();
      if ( vertex._index ) {
        let t = text.addChild(new PIXI.Text(String(vertex._index), CONFIG.canvasTextStyle));
        t.position.set(vertex.x, vertex.y);
      }
    }

    // Draw emitted rays
    for ( let ray of this.rays ) {
      const r = ray.result;
      if ( r ) {
        dg.lineStyle(2, 0x00FF00, r.collisions.length ? 1.0 : 0.33).moveTo(ray.A.x, ray.A.y).lineTo(ray.B.x, ray.B.y);
        for ( let c of r.collisions ) {
          dg.lineStyle(1, 0x000000).beginFill(0xFF0000).drawCircle(c.x, c.y, 6).endFill();
        }
      }
    }
    return dg;
  }

  /* -------------------------------------------- */

  /**
   * Visualize the polygon, displaying its computed area, rays, and collision points
   * @param {Ray} ray
   * @param {PolygonVertex[]} collisions
   * @private
   */
  _visualizeCollision(ray, collisions) {
    let dg = canvas.controls.debug;
    dg.clear();
    const limitColors = {
      [CONST.WALL_SENSE_TYPES.NONE]: 0x77E7E8,
      [CONST.WALL_SENSE_TYPES.NORMAL]: 0xFFFFBB,
      [CONST.WALL_SENSE_TYPES.LIMITED]: 0x81B90C,
      [CONST.WALL_SENSE_TYPES.PROXIMITY]: 0xFFFFBB,
      [CONST.WALL_SENSE_TYPES.DISTANCE]: 0xFFFFBB
    };

    // Draw edges
    for ( let edge of this.edges.values() ) {
      const c = limitColors[edge[this.config.type]];
      dg.lineStyle(4, c).moveTo(edge.a.x, edge.b.y).lineTo(edge.b.x, edge.b.y);
    }

    // Draw the attempted ray
    dg.lineStyle(4, 0x0066CC).moveTo(ray.A.x, ray.A.y).lineTo(ray.B.x, ray.B.y);

    // Draw collision points
    for ( let x of collisions ) {
      dg.lineStyle(1, 0x000000).beginFill(0xFF0000).drawCircle(x.x, x.y, 6).endFill();
    }
  }
}

/**
 * A Detection Mode which can be associated with any kind of sense/vision/perception.
 * A token could have multiple detection modes.
 */
class DetectionMode extends foundry.abstract.DataModel {

  /** @inheritDoc */
  static defineSchema() {
    const fields = foundry.data.fields;
    return {
      id: new fields.StringField({blank: false}),
      label: new fields.StringField({blank: false}),
      tokenConfig: new fields.BooleanField({initial: true}),       // If this DM is available in Token Config UI
      walls: new fields.BooleanField({initial: true}),             // If this DM is constrained by walls
      angle: new fields.BooleanField({initial: true}),             // If this DM is constrained by the vision angle
      type: new fields.NumberField({
        initial: this.DETECTION_TYPES.SIGHT,
        choices: Object.values(this.DETECTION_TYPES)
      })
    };
  }

  /* -------------------------------------------- */

  /**
   * Get the detection filter pertaining to this mode.
   * @returns {PIXI.Filter|undefined}
   */
  static getDetectionFilter() {
    return this._detectionFilter;
  }

  /**
   * An optional filter to apply on the target when it is detected with this mode.
   * @type {PIXI.Filter|undefined}
   */
  static _detectionFilter;

  static {
    /**
     * The type of the detection mode.
     * @enum {number}
     */
    Object.defineProperty(this, "DETECTION_TYPES", {value: Object.freeze({
      SIGHT: 0,       // Sight, and anything depending on light perception
      SOUND: 1,       // What you can hear. Includes echolocation for bats per example
      MOVE: 2,        // This is mostly a sense for touch and vibration, like tremorsense, movement detection, etc.
      OTHER: 3        // Can't fit in other types (smell, life sense, trans-dimensional sense, sense of humor...)
    })});

    /**
     * The identifier of the basic sight detection mode.
     * @type {string}
     */
    Object.defineProperty(this, "BASIC_MODE_ID", {value: "basicSight"});
  }

  /* -------------------------------------------- */
  /*  Visibility Testing                          */
  /* -------------------------------------------- */

  /**
   * Test visibility of a target object or array of points for a specific vision source.
   * @param {VisionSource} visionSource           The vision source being tested
   * @param {TokenDetectionMode} mode             The detection mode configuration
   * @param {CanvasVisibilityTestConfig} config   The visibility test configuration
   * @returns {boolean}                           Is the test target visible?
   */
  testVisibility(visionSource, mode, {object, tests}={}) {
    if ( !mode.enabled ) return false;
    if ( !this._canDetect(visionSource, object) ) return false;
    return tests.some(test => this._testPoint(visionSource, mode, object, test));
  }

  /* -------------------------------------------- */

  /**
   * Can this VisionSource theoretically detect a certain object based on its properties?
   * This check should not consider the relative positions of either object, only their state.
   * @param {VisionSource} visionSource   The vision source being tested
   * @param {PlaceableObject} target      The target object being tested
   * @returns {boolean}                   Can the target object theoretically be detected by this vision source?
   * @protected
   */
  _canDetect(visionSource, target) {
    const src = visionSource.object.document;
    const isSight = this.type === DetectionMode.DETECTION_TYPES.SIGHT;

    // Sight-based detection fails when blinded
    if ( isSight && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ) return false;

    // Detection fails if burrowing unless walls are ignored
    if ( this.walls && src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    if ( target instanceof Token ) {
      const tgt = target.document;

      // Sight-based detection cannot see invisible tokens
      if ( isSight && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) ) return false;

      // Burrowing tokens cannot be detected unless walls are ignored
      if ( this.walls && tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    }
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Evaluate a single test point to confirm whether it is visible.
   * Standard detection rules require that the test point be both within LOS and within range.
   * @param {VisionSource} visionSource           The vision source being tested
   * @param {TokenDetectionMode} mode             The detection mode configuration
   * @param {PlaceableObject} target              The target object being tested
   * @param {CanvasVisibilityTest} test           The test case being evaluated
   * @returns {boolean}
   * @protected
   */
  _testPoint(visionSource, mode, target, test) {
    if ( !this._testRange(visionSource, mode, target, test) ) return false;
    return this._testLOS(visionSource, mode, target, test);
  }

  /* -------------------------------------------- */

  /**
   * Test whether the line-of-sight requirement for detection is satisfied.
   * Always true if the detection mode bypasses walls, otherwise the test point must be contained by the LOS polygon.
   * The result of is cached for the vision source so that later checks for other detection modes do not repeat it.
   * @param {VisionSource} visionSource       The vision source being tested
   * @param {TokenDetectionMode} mode         The detection mode configuration
   * @param {PlaceableObject} target          The target object being tested
   * @param {CanvasVisibilityTest} test       The test case being evaluated
   * @returns {boolean}                       Is the LOS requirement satisfied for this test?
   * @protected
   */
  _testLOS(visionSource, mode, target, test) {
    if ( !this.walls ) return this._testAngle(visionSource, mode, target, test);
    const type = visionSource.constructor.sourceType;
    const isSight = type === "sight";
    if ( isSight && visionSource.blinded.darkness ) return false;
    if ( !this.angle && (visionSource.data.angle < 360) ) {
      // Constrained by walls but not by vision angle
      return !CONFIG.Canvas.polygonBackends[type].testCollision(
        { x: visionSource.x, y: visionSource.y },
        test.point,
        { type, mode: "any", source: visionSource, useThreshold: true, includeDarkness: isSight }
      );
    }
    // Constrained by walls and vision angle
    let hasLOS = test.los.get(visionSource);
    if ( hasLOS === undefined ) {
      hasLOS = visionSource.los.contains(test.point.x, test.point.y);
      test.los.set(visionSource, hasLOS);
    }
    return hasLOS;
  }

  /* -------------------------------------------- */

  /**
   * Test whether the target is within the vision angle.
   * @param {VisionSource} visionSource       The vision source being tested
   * @param {TokenDetectionMode} mode         The detection mode configuration
   * @param {PlaceableObject} target          The target object being tested
   * @param {CanvasVisibilityTest} test       The test case being evaluated
   * @returns {boolean}                       Is the point within the vision angle?
   * @protected
   */
  _testAngle(visionSource, mode, target, test) {
    if ( !this.angle ) return true;
    const { angle, rotation, externalRadius } = visionSource.data;
    if ( angle >= 360 ) return true;
    const point = test.point;
    const dx = point.x - visionSource.x;
    const dy = point.y - visionSource.y;
    if ( (dx * dx) + (dy * dy) <= (externalRadius * externalRadius) ) return true;
    const aMin = rotation + 90 - (angle / 2);
    const a = Math.toDegrees(Math.atan2(dy, dx));
    return (((a - aMin) % 360) + 360) % 360 <= angle;
  }

  /* -------------------------------------------- */

  /**
   * Verify that a target is in range of a source.
   * @param {VisionSource} visionSource           The vision source being tested
   * @param {TokenDetectionMode} mode             The detection mode configuration
   * @param {PlaceableObject} target              The target object being tested
   * @param {CanvasVisibilityTest} test           The test case being evaluated
   * @returns {boolean}                           Is the target within range?
   * @protected
   */
  _testRange(visionSource, mode, target, test) {
    if ( mode.range === null ) return true;
    if ( mode.range <= 0 ) return false;
    const radius = visionSource.object.getLightRadius(mode.range);
    const dx = test.point.x - visionSource.x;
    const dy = test.point.y - visionSource.y;
    return ((dx * dx) + (dy * dy)) <= (radius * radius);
  }
}

/* -------------------------------------------- */

/**
 * This detection mode tests whether the target is visible due to being illuminated by a light source.
 * By default tokens have light perception with an infinite range if light perception isn't explicitely
 * configured.
 */
class DetectionModeLightPerception extends DetectionMode {

  /** @override */
  _canDetect(visionSource, target) {

    // Cannot see while blinded or burrowing
    const src = visionSource.object.document;
    if ( src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND)
      || src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;

    // Cannot see invisible or burrowing creatures
    if ( target instanceof Token ) {
      const tgt = target.document;
      if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE)
        || tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    }
    return true;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _testPoint(visionSource, mode, target, test) {
    if ( !super._testPoint(visionSource, mode, target, test) ) return false;
    return canvas.effects.testInsideLight(test.point, test.elevation);
  }
}

/* -------------------------------------------- */

/**
 * A special detection mode which models a form of darkvision (night vision).
 * This mode is the default case which is tested first when evaluating visibility of objects.
 */
class DetectionModeBasicSight extends DetectionMode {

  /** @override */
  _canDetect(visionSource, target) {

    // Cannot see while blinded or burrowing
    const src = visionSource.object.document;
    if ( src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND)
      || src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;

    // Cannot see invisible or burrowing creatures
    if ( target instanceof Token ) {
      const tgt = target.document;
      if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE)
        || tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    }
    return true;
  }
}

/* -------------------------------------------- */

/**
 * Detection mode that see invisible creatures.
 * This detection mode allows the source to:
 * - See/Detect the invisible target as if visible.
 * - The "See" version needs sight and is affected by blindness
 */
class DetectionModeInvisibility extends DetectionMode {

  /** @override */
  static getDetectionFilter() {
    return this._detectionFilter ??= GlowOverlayFilter.create({
      glowColor: [0, 0.60, 0.33, 1]
    });
  }

  /** @override */
  _canDetect(visionSource, target) {
    if ( !(target instanceof Token) ) return false;
    const tgt = target.document;

    // Only invisible tokens can be detected
    if ( !tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) ) return false;
    const src = visionSource.object.document;
    const isSight = this.type === DetectionMode.DETECTION_TYPES.SIGHT;

    // Sight-based detection fails when blinded
    if ( isSight && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ) return false;

    // Detection fails when the source or target token is burrowing unless walls are ignored
    if ( this.walls ) {
      if ( src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
      if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    }
    return true;
  }
}

/* -------------------------------------------- */

/**
 * Detection mode that see creatures in contact with the ground.
 */
class DetectionModeTremor extends DetectionMode {
  /** @override */
  static getDetectionFilter() {
    return this._detectionFilter ??= OutlineOverlayFilter.create({
      outlineColor: [1, 0, 1, 1],
      knockout: true,
      wave: true
    });
  }

  /** @override */
  _canDetect(visionSource, target) {
    if ( !(target instanceof Token) ) return false;
    const tgt = target.document;

    // Flying and hovering tokens cannot be detected
    if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.FLY) ) return false;
    if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.HOVER) ) return false;
    return true;
  }
}

/* -------------------------------------------- */

/**
 * Detection mode that see ALL creatures (no blockers).
 * If not constrained by walls, see everything within the range.
 */
class DetectionModeAll extends DetectionMode {
  /** @override */
  static getDetectionFilter() {
    return this._detectionFilter ??= OutlineOverlayFilter.create({
      outlineColor: [0.85, 0.85, 1.0, 1],
      knockout: true
    });
  }

  /** @override */
  _canDetect(visionSource, target) {
    const src = visionSource.object.document;
    const isSight = this.type === DetectionMode.DETECTION_TYPES.SIGHT;

    // Sight-based detection fails when blinded
    if ( isSight && src.hasStatusEffect(CONFIG.specialStatusEffects.BLIND) ) return false;

    // Detection fails when the source or target token is burrowing unless walls are ignored
    if ( !this.walls ) return true;
    if ( src.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    if ( target instanceof Token ) {
      const tgt = target.document;
      if ( tgt.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    }
    return true;
  }
}

/**
 * A fog of war management class which is the singleton canvas.fog instance.
 * @category - Canvas
 */
class FogManager {

  /**
   * The FogExploration document which applies to this canvas view
   * @type {FogExploration|null}
   */
  exploration = null;

  /**
   * A status flag for whether the layer initialization workflow has succeeded
   * @type {boolean}
   * @private
   */
  #initialized = false;

  /**
   * Track whether we have pending fog updates which have not yet been saved to the database
   * @type {boolean}
   * @internal
   */
  _updated = false;

  /**
   * Texture extractor
   * @type {TextureExtractor}
   */
  get extractor() {
    return this.#extractor;
  }

  #extractor;

  /**
   * The fog refresh count.
   * If > to the refresh threshold, the fog texture is saved to database. It is then reinitialized to 0.
   * @type {number}
   */
  #refreshCount = 0;

  /**
   * Matrix used for fog rendering transformation.
   * @type {PIXI.Matrix}
   */
  #renderTransform = new PIXI.Matrix();

  /**
   * Define the number of fog refresh needed before the fog texture is extracted and pushed to the server.
   * @type {number}
   */
  static COMMIT_THRESHOLD = 70;

  /**
   * A debounced function to save fog of war exploration once a continuous stream of updates has concluded.
   * @type {Function}
   */
  #debouncedSave;

  /**
   * Handling of the concurrency for fog loading, saving and reset.
   * @type {Semaphore}
   */
  #queue = new foundry.utils.Semaphore();

  /* -------------------------------------------- */
  /*  Fog Manager Properties                      */
  /* -------------------------------------------- */

  /**
   * The exploration SpriteMesh which holds the fog exploration texture.
   * @type {SpriteMesh}
   */
  get sprite() {
    return this.#explorationSprite || (this.#explorationSprite = this._createExplorationObject());
  }

  #explorationSprite;

  /* -------------------------------------------- */

  /**
   * The configured options used for the saved fog-of-war texture.
   * @type {FogTextureConfiguration}
   */
  get textureConfiguration() {
    return canvas.visibility.textureConfiguration;
  }

  /* -------------------------------------------- */

  /**
   * Does the currently viewed Scene support Token field of vision?
   * @type {boolean}
   */
  get tokenVision() {
    return canvas.scene.tokenVision;
  }

  /* -------------------------------------------- */

  /**
   * Does the currently viewed Scene support fog of war exploration?
   * @type {boolean}
   */
  get fogExploration() {
    return canvas.scene.fog.exploration;
  }

  /* -------------------------------------------- */
  /*  Fog of War Management                       */
  /* -------------------------------------------- */

  /**
   * Create the exploration display object with or without a provided texture.
   * @param {PIXI.Texture|PIXI.RenderTexture} [tex] Optional exploration texture.
   * @returns {DisplayObject}
   * @internal
   */
  _createExplorationObject(tex) {
    return new SpriteMesh(tex ?? Canvas.getRenderTexture({
      clearColor: [0, 0, 0, 1],
      textureConfiguration: this.textureConfiguration
    }), FogSamplerShader);
  }

  /* -------------------------------------------- */

  /**
   * Initialize fog of war - resetting it when switching scenes or re-drawing the canvas
   * @returns {Promise<void>}
   */
  async initialize() {
    this.#initialized = false;

    // Create a TextureExtractor instance
    if ( this.#extractor === undefined ) {
      try {
        this.#extractor = new TextureExtractor(canvas.app.renderer, {
          callerName: "FogExtractor",
          controlHash: true,
          format: PIXI.FORMATS.RED
        });
      } catch(e) {
        this.#extractor = null;
        console.error(e);
      }
    }
    this.#extractor?.reset();

    // Bind a debounced save handler
    this.#debouncedSave = foundry.utils.debounce(this.save.bind(this), 2000);

    // Load the initial fog texture
    await this.load();
    this.#initialized = true;
  }

  /* -------------------------------------------- */

  /**
   * Clear the fog and reinitialize properties (commit and save in non reset mode)
   * @returns {Promise<void>}
   */
  async clear() {
    // Save any pending exploration
    try {
      await this.save();
    } catch(e) {
      ui.notifications.error("Failed to save fog exploration");
      console.error(e);
    }

    // Deactivate current fog exploration
    this.#initialized = false;
    this.#deactivate();
  }

  /* -------------------------------------------- */

  /**
   * Once a new Fog of War location is explored, composite the explored container with the current staging sprite.
   * Once the number of refresh is > to the commit threshold, save the fog texture to the database.
   */
  commit() {
    const vision = canvas.visibility.vision;
    if ( !vision?.children.length || !this.fogExploration || !this.tokenVision ) return;
    if ( !this.#explorationSprite?.texture.valid ) return;

    // Get a staging texture or clear and render into the sprite if its texture is a RT
    // and render the entire fog container to it
    const dims = canvas.dimensions;
    const isRenderTex = this.#explorationSprite.texture instanceof PIXI.RenderTexture;
    const tex = isRenderTex ? this.#explorationSprite.texture : Canvas.getRenderTexture({
      clearColor: [0, 0, 0, 1],
      textureConfiguration: this.textureConfiguration
    });
    this.#renderTransform.tx = -dims.sceneX;
    this.#renderTransform.ty = -dims.sceneY;

    // Render the currently revealed vision (preview excluded) to the texture
    vision.containmentFilter.enabled = canvas.visibility.needsContainment;
    vision.light.preview.visible = false;
    vision.light.mask.preview.visible = false;
    vision.sight.preview.visible = false;
    canvas.app.renderer.render(isRenderTex ? vision : this.#explorationSprite, {
      renderTexture: tex,
      clear: false,
      transform: this.#renderTransform
    });
    vision.light.preview.visible = true;
    vision.light.mask.preview.visible = true;
    vision.sight.preview.visible = true;
    vision.containmentFilter.enabled = false;

    if ( !isRenderTex ) this.#explorationSprite.texture.destroy(true);
    this.#explorationSprite.texture = tex;
    this._updated = true;

    if ( !this.exploration ) {
      const fogExplorationCls = getDocumentClass("FogExploration");
      this.exploration = new fogExplorationCls();
    }

    // Schedule saving the texture to the database
    if ( this.#refreshCount > FogManager.COMMIT_THRESHOLD ) {
      this.#debouncedSave();
      this.#refreshCount = 0;
    }
    else this.#refreshCount++;
  }

  /* -------------------------------------------- */

  /**
   * Load existing fog of war data from local storage and populate the initial exploration sprite
   * @returns {Promise<(PIXI.Texture|void)>}
   */
  async load() {
    return await this.#queue.add(this.#load.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Load existing fog of war data from local storage and populate the initial exploration sprite
   * @returns {Promise<(PIXI.Texture|void)>}
   */
  async #load() {
    if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Loading saved FogExploration for Scene.");

    this.#deactivate();

    // Take no further action if token vision is not enabled
    if ( !this.tokenVision ) return;

    // Load existing FOW exploration data or create a new placeholder
    const fogExplorationCls = /** @type {typeof FogExploration} */ getDocumentClass("FogExploration");
    this.exploration = await fogExplorationCls.load();

    // Extract and assign the fog data image
    const assign = (tex, resolve) => {
      if ( this.#explorationSprite?.texture === tex ) return resolve(tex);
      this.#explorationSprite?.destroy(true);
      this.#explorationSprite = this._createExplorationObject(tex);
      canvas.visibility.resetExploration();
      canvas.perception.initialize();
      resolve(tex);
    };

    // Initialize the exploration sprite if no exploration data exists
    if ( !this.exploration ) {
      return await new Promise(resolve => {
        assign(Canvas.getRenderTexture({
          clearColor: [0, 0, 0, 1],
          textureConfiguration: this.textureConfiguration
        }), resolve);
      });
    }
    // Otherwise load the texture from the exploration data
    return await new Promise(resolve => {
      let tex = this.exploration.getTexture();
      if ( tex === null ) assign(Canvas.getRenderTexture({
        clearColor: [0, 0, 0, 1],
        textureConfiguration: this.textureConfiguration
      }), resolve);
      else if ( tex.baseTexture.valid ) assign(tex, resolve);
      else tex.on("update", tex => assign(tex, resolve));
    });
  }

  /* -------------------------------------------- */

  /**
   * Dispatch a request to reset the fog of war exploration status for all users within this Scene.
   * Once the server has deleted existing FogExploration documents, the _onReset handler will re-draw the canvas.
   */
  async reset() {
    if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Resetting fog of war exploration for Scene.");
    game.socket.emit("resetFog", canvas.scene.id);
  }

  /* -------------------------------------------- */

  /**
   * Request a fog of war save operation.
   * Note: if a save operation is pending, we're waiting for its conclusion.
   */
  async save() {
    return await this.#queue.add(this.#save.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Request a fog of war save operation.
   * Note: if a save operation is pending, we're waiting for its conclusion.
   */
  async #save() {
    if ( !this._updated ) return;
    this._updated = false;
    const exploration = this.exploration;
    if ( CONFIG.debug.fog.manager ) {
      console.debug("FogManager | Initiate non-blocking extraction of the fog of war progress.");
    }
    if ( !this.#extractor ) {
      console.error("FogManager | Browser does not support texture extraction.");
      return;
    }

    // Get compressed base64 image from the fog texture
    const base64Image = await this._extractBase64();

    // If the exploration changed, the fog was reloaded while the pixels were extracted
    if ( this.exploration !== exploration ) return;

    // Need to skip?
    if ( !base64Image ) {
      if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Fog of war has not changed. Skipping db operation.");
      return;
    }

    // Update the fog exploration document
    const updateData = this._prepareFogUpdateData(base64Image);
    await this.#updateFogExploration(updateData);
  }

  /* -------------------------------------------- */

  /**
   * Extract fog data as a base64 string
   * @returns {Promise<string>}
   * @protected
   */
  async _extractBase64() {
    try {
      return this.#extractor.extract({
        texture: this.#explorationSprite.texture,
        compression: TextureExtractor.COMPRESSION_MODES.BASE64,
        type: "image/webp",
        quality: 0.8,
        debug: CONFIG.debug.fog.extractor
      });
    } catch(err) {
      // FIXME this is needed because for some reason .extract() may throw a boolean false instead of an Error
      throw new Error("Fog of War base64 extraction failed");
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare the data that will be used to update the FogExploration document.
   * @param {string} base64Image              The extracted base64 image data
   * @returns {Partial<FogExplorationData>}   Exploration data to update
   * @protected
   */
  _prepareFogUpdateData(base64Image) {
    return {explored: base64Image, timestamp: Date.now()};
  }

  /* -------------------------------------------- */

  /**
   * Update the fog exploration document with provided data.
   * @param {object} updateData
   * @returns {Promise<void>}
   */
  async #updateFogExploration(updateData) {
    if ( !game.scenes.has(canvas.scene?.id) ) return;
    if ( !this.exploration ) return;
    if ( CONFIG.debug.fog.manager ) console.debug("FogManager | Saving fog of war progress into exploration document.");
    if ( !this.exploration.id ) {
      this.exploration.updateSource(updateData);
      this.exploration = await this.exploration.constructor.create(this.exploration.toJSON(), {loadFog: false});
    }
    else await this.exploration.update(updateData, {loadFog: false});
  }

  /* -------------------------------------------- */

  /**
   * Deactivate fog of war.
   * Clear all shared containers by unlinking them from their parent.
   * Destroy all stored textures and graphics.
   */
  #deactivate() {
    // Remove the current exploration document
    this.exploration = null;
    this.#extractor?.reset();

    // Destroy current exploration texture and provide a new one with transparency
    if ( this.#explorationSprite && !this.#explorationSprite.destroyed ) this.#explorationSprite.destroy(true);
    this.#explorationSprite = undefined;

    this._updated = false;
    this.#refreshCount = 0;
  }

  /* -------------------------------------------- */

  /**
   * If fog of war data is reset from the server, deactivate the current fog and initialize the exploration.
   * @returns {Promise}
   * @internal
   */
  async _handleReset() {
    return await this.#queue.add(this.#handleReset.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * If fog of war data is reset from the server, deactivate the current fog and initialize the exploration.
   * @returns {Promise}
   */
  async #handleReset() {
    ui.notifications.info("Fog of War exploration progress was reset for this Scene");

    // Remove the current exploration document
    this.#deactivate();

    // Reset exploration in the visibility layer
    canvas.visibility.resetExploration();

    // Refresh perception
    canvas.perception.initialize();
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  get pending() {
    const msg = "pending is deprecated and redirected to the exploration container";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return canvas.visibility.explored;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  get revealed() {
    const msg = "revealed is deprecated and redirected to the exploration container";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return canvas.visibility.explored;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  update(source, force=false) {
    const msg = "update is obsolete and always returns true. The fog exploration does not record position anymore.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return true;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  get resolution() {
    const msg = "resolution is deprecated and redirected to CanvasVisibility#textureConfiguration";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return canvas.visibility.textureConfiguration;
  }
}

/**
 * A helper class which manages the refresh workflow for perception layers on the canvas.
 * This controls the logic which batches multiple requested updates to minimize the amount of work required.
 * A singleton instance is available as {@link Canvas#perception}.
 */
class PerceptionManager extends RenderFlagsMixin(Object) {

  /**
   * @typedef {RenderFlags} PerceptionManagerFlags
   * @property {boolean} initializeLighting       Re-initialize the entire lighting configuration. An aggregate behavior
   *                                              which does no work directly but propagates to set several other flags.
   * @property {boolean} initializeVision         Re-initialize the entire vision configuration.
   *                                              See {@link CanvasVisibility#initializeSources}.
   * @property {boolean} initializeVisionModes    Initialize the active vision modes.
   *                                              See {@link CanvasVisibility#initializeVisionMode}.
   * @property {boolean} initializeSounds         Re-initialize the entire ambient sound configuration.
   *                                              See {@link SoundsLayer#initializeSources}.
   * @property {boolean} refreshEdges             Recompute intersections between all registered edges.
   *                                              See {@link CanvasEdges#refresh}.
   * @property {boolean} refreshLighting          Refresh the rendered appearance of lighting
   * @property {boolean} refreshLightSources      Update the configuration of light sources
   * @property {boolean} refreshOcclusion         Refresh occlusion
   * @property {boolean} refreshPrimary           Refresh the contents of the PrimaryCanvasGroup mesh
   * @property {boolean} refreshSounds            Refresh the audio state of ambient sounds
   * @property {boolean} refreshVision            Refresh the rendered appearance of vision
   * @property {boolean} refreshVisionSources     Update the configuration of vision sources
   * @property {boolean} soundFadeDuration        Apply a fade duration to sound refresh workflow
   */

  /** @override */
  static RENDER_FLAGS = {

    // Edges
    refreshEdges: {},

    // Light and Darkness Sources
    initializeLighting: {propagate: ["initializeDarknessSources", "initializeLightSources"]},
    initializeDarknessSources: {propagate: ["refreshLighting", "refreshVision", "refreshEdges"]},
    initializeLightSources: {propagate: ["refreshLighting", "refreshVision"]},
    refreshLighting: {propagate: ["refreshLightSources"]},
    refreshLightSources: {},

    // Vision
    initializeVisionModes: {propagate: ["refreshVisionSources", "refreshLighting", "refreshPrimary"]},
    initializeVision: {propagate: ["initializeVisionModes", "refreshVision"]},
    refreshVision: {propagate: ["refreshVisionSources", "refreshOcclusionMask"]},
    refreshVisionSources: {},

    // Primary Canvas Group
    refreshPrimary: {},
    refreshOcclusion: {propagate: ["refreshOcclusionStates", "refreshOcclusionMask"]},
    refreshOcclusionStates: {},
    refreshOcclusionMask: {},

    // Sound
    initializeSounds: {propagate: ["refreshSounds"]},
    refreshSounds: {},
    soundFadeDuration: {},

    /** @deprecated since v12 */
    refreshTiles: {
      propagate: ["refreshOcclusion"],
      deprecated: {message: "The refreshTiles flag is deprecated in favor of refreshOcclusion",
        since: 12, until: 14, alias: true}
    },
    /** @deprecated since v12 */
    identifyInteriorWalls: {
      propagate: ["initializeLighting", "initializeVision"],
      deprecated: {
        message: "The identifyInteriorWalls is now obsolete and has no replacement.",
        since: 12, until: 14, alias: true
      }
    },
    /** @deprecated since v11 */
    forceUpdateFog: {
      propagate: ["refreshVision"],
      deprecated: {
        message: "The forceUpdateFog flag is now obsolete and has no replacement. "
          + "The fog is now always updated when the visibility is refreshed", since: 11, until: 13, alias: true
      }
    }
  };

  static #deprecatedFlags = ["refreshTiles", "identifyInteriorWalls", "forceUpdateFog"];

  /** @override */
  static RENDER_FLAG_PRIORITY = "PERCEPTION";

  /* -------------------------------------------- */

  /** @override */
  applyRenderFlags() {
    if ( !this.renderFlags.size ) return;
    const flags = this.renderFlags.clear();

    // Initialize darkness sources
    if ( flags.initializeDarknessSources ) canvas.effects.initializeDarknessSources();

    // Recompute edge intersections
    if ( flags.refreshEdges ) canvas.edges.refresh();

    // Initialize positive light sources
    if ( flags.initializeLightSources ) canvas.effects.initializeLightSources();

    // Initialize active vision sources
    if ( flags.initializeVision ) canvas.visibility.initializeSources();

    // Initialize the active vision mode
    if ( flags.initializeVisionModes ) canvas.visibility.initializeVisionMode();

    // Initialize active sound sources
    if ( flags.initializeSounds ) canvas.sounds.initializeSources();

    // Refresh light, vision, and sound sources
    if ( flags.refreshLightSources ) canvas.effects.refreshLightSources();
    if ( flags.refreshVisionSources ) canvas.effects.refreshVisionSources();
    if ( flags.refreshSounds ) canvas.sounds.refresh({fade: flags.soundFadeDuration ? 250 : 0});

    // Refresh the appearance of the Primary Canvas Group environment
    if ( flags.refreshPrimary ) canvas.primary.refreshPrimarySpriteMesh();
    if ( flags.refreshLighting ) canvas.effects.refreshLighting();
    if ( flags.refreshVision ) canvas.visibility.refresh();

    // Update roof occlusion states based on token positions and vision
    // TODO: separate occlusion state testing from CanvasOcclusionMask
    if ( flags.refreshOcclusion ) canvas.masks.occlusion.updateOcclusion();
    else {
      if ( flags.refreshOcclusionMask ) canvas.masks.occlusion._updateOcclusionMask();
      if ( flags.refreshOcclusionStates ) canvas.masks.occlusion._updateOcclusionStates();
    }

    // Deprecated flags
    for ( const f of PerceptionManager.#deprecatedFlags ) {
      if ( flags[f] ) {
        const {message, since, until} = PerceptionManager.RENDER_FLAGS[f].deprecated;
        foundry.utils.logCompatibilityWarning(message, {since, until});
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Update perception manager flags which configure which behaviors occur on the next frame render.
   * @param {object} flags        Flag values (true) to assign where the keys belong to PerceptionManager.FLAGS
   */
  update(flags) {
    if ( !canvas.ready ) return;
    this.renderFlags.set(flags);
  }

  /* -------------------------------------------- */

  /**
   * A helper function to perform an immediate initialization plus incremental refresh.
   */
  initialize() {
    return this.update({
      refreshEdges: true,
      initializeLighting: true,
      initializeVision: true,
      initializeSounds: true,
      refreshOcclusion: true
    });
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  refresh() {
    foundry.utils.logCompatibilityWarning("PerceptionManager#refresh is deprecated in favor of assigning granular "
      + "refresh flags", {since: 12, until: 14});
    return this.update({
      refreshLighting: true,
      refreshVision: true,
      refreshSounds: true,
      refreshOcclusion: true
    });
  }
}

/**
 * A special subclass of DataField used to reference an AbstractBaseShader definition.
 */
class ShaderField extends foundry.data.fields.DataField {

  /** @inheritdoc */
  static get _defaults() {
    const defaults = super._defaults;
    defaults.nullable = true;
    defaults.initial = undefined;
    return defaults;
  }

  /** @override */
  _cast(value) {
    if ( !foundry.utils.isSubclass(value, AbstractBaseShader) ) {
      throw new Error("The value provided to a ShaderField must be an AbstractBaseShader subclass.");
    }
    return value;
  }
}

/**
 * A Vision Mode which can be selected for use by a Token.
 * The selected Vision Mode alters the appearance of various aspects of the canvas while that Token is the POV.
 */
class VisionMode extends foundry.abstract.DataModel {
  /**
   * Construct a Vision Mode using provided configuration parameters and callback functions.
   * @param {object} data             Data which fulfills the model defined by the VisionMode schema.
   * @param {object} [options]        Additional options passed to the DataModel constructor.
   */
  constructor(data={}, options={}) {
    super(data, options);
    this.animated = options.animated ?? false;
  }

  /** @inheritDoc */
  static defineSchema() {
    const fields = foundry.data.fields;
    const shaderSchema = () => new fields.SchemaField({
      shader: new ShaderField(),
      uniforms: new fields.ObjectField()
    });
    const lightingSchema = () => new fields.SchemaField({
      visibility: new fields.NumberField({
        initial: this.LIGHTING_VISIBILITY.ENABLED,
        choices: Object.values(this.LIGHTING_VISIBILITY)
      }),
      postProcessingModes: new fields.ArrayField(new fields.StringField()),
      uniforms: new fields.ObjectField()
    });

    // Return model schema
    return {
      id: new fields.StringField({blank: false}),
      label: new fields.StringField({blank: false}),
      tokenConfig: new fields.BooleanField({initial: true}),
      canvas: new fields.SchemaField({
        shader: new ShaderField(),
        uniforms: new fields.ObjectField()
      }),
      lighting: new fields.SchemaField({
        background: lightingSchema(),
        coloration: lightingSchema(),
        illumination: lightingSchema(),
        darkness: lightingSchema(),
        levels: new fields.ObjectField({
          validate: o => {
            const values = Object.values(CONST.LIGHTING_LEVELS);
            return Object.entries(o).every(([k, v]) => values.includes(Number(k)) && values.includes(v));
          },
          validationError: "may only contain a mapping of keys from VisionMode.LIGHTING_LEVELS"
        }),
        multipliers: new fields.ObjectField({
          validate: o => {
            const values = Object.values(CONST.LIGHTING_LEVELS);
            return Object.entries(o).every(([k, v]) => values.includes(Number(k)) && Number.isFinite(v));
          },
          validationError: "must provide a mapping of keys from VisionMode.LIGHTING_LEVELS to numeric multiplier values"
        })
      }),
      vision: new fields.SchemaField({
        background: shaderSchema(),
        coloration: shaderSchema(),
        illumination: shaderSchema(),
        darkness: new fields.SchemaField({
          adaptive: new fields.BooleanField({initial: true})
        }),
        defaults: new fields.SchemaField({
          color: new fields.ColorField({required: false, initial: undefined}),
          attenuation: new fields.AlphaField({required: false, initial: undefined}),
          brightness: new fields.NumberField({required: false, initial: undefined, nullable: false, min: -1, max: 1}),
          saturation: new fields.NumberField({required: false, initial: undefined, nullable: false, min: -1, max: 1}),
          contrast: new fields.NumberField({required: false, initial: undefined, nullable: false, min: -1, max: 1})
        }),
        preferred: new fields.BooleanField({initial: false})
      })
    };
  }

  /**
   * The lighting illumination levels which are supported.
   * @enum {number}
   */
  static LIGHTING_LEVELS = CONST.LIGHTING_LEVELS;

  /**
   * Flags for how each lighting channel should be rendered for the currently active vision modes:
   * - Disabled: this lighting layer is not rendered, the shaders does not decide.
   * - Enabled: this lighting layer is rendered normally, and the shaders can choose if they should be rendered or not.
   * - Required: the lighting layer is rendered, the shaders does not decide.
   * @enum {number}
   */
  static LIGHTING_VISIBILITY = {
    DISABLED: 0,
    ENABLED: 1,
    REQUIRED: 2
  };

  /**
   * A flag for whether this vision source is animated
   * @type {boolean}
   */
  animated = false;

  /**
   * Does this vision mode enable light sources?
   * True unless it disables lighting entirely.
   * @type {boolean}
   */
  get perceivesLight() {
    const {background, illumination, coloration} = this.lighting;
    return !!(background.visibility || illumination.visibility || coloration.visibility);
  }

  /**
   * Special activation handling that could be implemented by VisionMode subclasses
   * @param {VisionSource} source   Activate this VisionMode for a specific source
   * @abstract
   */
  _activate(source) {}

  /**
   * Special deactivation handling that could be implemented by VisionMode subclasses
   * @param {VisionSource} source   Deactivate this VisionMode for a specific source
   * @abstract
   */
  _deactivate(source) {}

  /**
   * Special handling which is needed when this Vision Mode is activated for a VisionSource.
   * @param {VisionSource} source   Activate this VisionMode for a specific source
   */
  activate(source) {
    if ( source._visionModeActivated ) return;
    source._visionModeActivated = true;
    this._activate(source);
  }

  /**
   * Special handling which is needed when this Vision Mode is deactivated for a VisionSource.
   * @param {VisionSource} source   Deactivate this VisionMode for a specific source
   */
  deactivate(source) {
    if ( !source._visionModeActivated ) return;
    source._visionModeActivated = false;
    this._deactivate(source);
  }

  /**
   * An animation function which runs every frame while this Vision Mode is active.
   * @param {number} dt         The deltaTime passed by the PIXI Ticker
   */
  animate(dt) {
    return foundry.canvas.sources.PointVisionSource.prototype.animateTime.call(this, dt);
  }
}

/**
 * An implementation of the Weiler Atherton algorithm for clipping polygons.
 * This currently only handles combinations that will not result in any holes.
 * Support may be added for holes in the future.
 *
 * This algorithm is faster than the Clipper library for this task because it relies on the unique properties of the
 * circle, ellipse, or convex simple clip object.
 * It is also more precise in that it uses the actual intersection points between the circle/ellipse and polygon,
 * instead of relying on the polygon approximation of the circle/ellipse to find the intersection points.
 *
 * For more explanation of the underlying algorithm, see:
 * https://en.wikipedia.org/wiki/Weiler%E2%80%93Atherton_clipping_algorithm
 * https://www.geeksforgeeks.org/weiler-atherton-polygon-clipping-algorithm
 * https://h-educate.in/weiler-atherton-polygon-clipping-algorithm/
 */
class WeilerAthertonClipper {
  /**
   * Construct a WeilerAthertonClipper instance used to perform the calculation.
   * @param {PIXI.Polygon} polygon    Polygon to clip
   * @param {PIXI.Rectangle|PIXI.Circle} clipObject  Object used to clip the polygon
   * @param {number} clipType         Type of clip to use
   * @param {object} clipOpts         Object passed to the clippingObject methods toPolygon and pointsBetween
   */
  constructor(polygon, clipObject, clipType, clipOpts) {
    if ( !polygon.isPositive ) {
      const msg = "WeilerAthertonClipper#constructor needs a subject polygon with a positive signed area.";
      throw new Error(msg);
    }
    clipType ??= this.constructor.CLIP_TYPES.INTERSECT;
    clipOpts ??= {};
    this.polygon = polygon;
    this.clipObject = clipObject;
    this.config = { clipType, clipOpts };
  }

  /**
   * The supported clip types.
   * Values are equivalent to those in ClipperLib.ClipType.
   * @enum {number}
   */
  static CLIP_TYPES = Object.freeze({
    INTERSECT: 0,
    UNION: 1
  });

  /**
   * The supported intersection types.
   * @enum {number}
   */
  static INTERSECTION_TYPES = Object.freeze({
    OUT_IN: -1,
    IN_OUT: 1,
    TANGENT: 0
  });

  /** @type {PIXI.Polygon} */
  polygon;

  /** @type {PIXI.Rectangle|PIXI.Circle} */
  clipObject;

  /**
   * Configuration settings
   * @type {object} [config]
   * @param {WeilerAthertonClipper.CLIP_TYPES} [config.clipType]     One of CLIP_TYPES
   * @param {object} [config.clipOpts]      Object passed to the clippingObject methods
   *                                        toPolygon and pointsBetween
   */
  config = {};

  /* -------------------------------------------- */

  /**
   * Union a polygon and clipObject using the Weiler Atherton algorithm.
   * @param {PIXI.Polygon} polygon                    Polygon to clip
   * @param {PIXI.Rectangle|PIXI.Circle} clipObject   Object to clip against the polygon
   * @param {object} clipOpts                         Options passed to the clipping object
   *                                                  methods toPolygon and pointsBetween
   * @returns {PIXI.Polygon[]}
   */
  static union(polygon, clipObject, clipOpts = {}) {
    return this.combine(polygon, clipObject, {clipType: this.CLIP_TYPES.UNION, ...clipOpts});
  }

  /* -------------------------------------------- */

  /**
   * Intersect a polygon and clipObject using the Weiler Atherton algorithm.
   * @param {PIXI.Polygon} polygon                    Polygon to clip
   * @param {PIXI.Rectangle|PIXI.Circle} clipObject   Object to clip against the polygon
   * @param {object} clipOpts                         Options passed to the clipping object
   *                                                  methods toPolygon and pointsBetween
   * @returns {PIXI.Polygon[]}
   */
  static intersect(polygon, clipObject, clipOpts = {}) {
    return this.combine(polygon, clipObject, {clipType: this.CLIP_TYPES.INTERSECT, ...clipOpts});
  }

  /* -------------------------------------------- */

  /**
   * Clip a given clipObject using the Weiler-Atherton algorithm.
   *
   * At the moment, this will return a single PIXI.Polygon in the array unless clipType is a union and the polygon
   * and clipObject do not overlap, in which case the [polygon, clipObject.toPolygon()] array will be returned.
   * If this algorithm is expanded in the future to handle holes, an array of polygons may be returned.
   *
   * @param {PIXI.Polygon} polygon                    Polygon to clip
   * @param {PIXI.Rectangle|PIXI.Circle} clipObject   Object to clip against the polygon
   * @param {object} [options]                        Options which configure how the union or intersection is computed
   * @param {WeilerAthertonClipper.CLIP_TYPES} [options.clipType]   One of CLIP_TYPES
   * @param {boolean} [options.canMutate]             If the WeilerAtherton constructor could mutate or not
   *                                                  the subject polygon points
   * @param {object} [options.clipOpts]               Options passed to the WeilerAthertonClipper constructor
   * @returns {PIXI.Polygon[]}                        Array of polygons and clipObjects
   */
  static combine(polygon, clipObject, {clipType, canMutate, ...clipOpts}={}) {
    if ( (clipType !== this.CLIP_TYPES.INTERSECT) && (clipType !== this.CLIP_TYPES.UNION) ) {
      throw new Error("The Weiler-Atherton clipping algorithm only supports INTERSECT or UNION clip types.");
    }
    if ( canMutate && !polygon.isPositive ) polygon.reverseOrientation();
    const wa = new this(polygon, clipObject, clipType, clipOpts);
    const trackingArray = wa.#buildPointTrackingArray();
    if ( !trackingArray.length ) return this.testForEnvelopment(polygon, clipObject, clipType, clipOpts);
    return wa.#combineNoHoles(trackingArray);
  }

  /* -------------------------------------------- */

  /**
   * Clip the polygon with the clipObject, assuming no holes will be created.
   * For a union or intersect with no holes, a single pass through the intersections will
   * build the resulting union shape.
   * @param {PolygonVertex[]} trackingArray   Array of linked points and intersections
   * @returns {[PIXI.Polygon]}
   */
  #combineNoHoles(trackingArray) {
    const clipType = this.config.clipType;
    const ln = trackingArray.length;
    let prevIx = trackingArray[ln - 1];
    let wasTracingPolygon = (prevIx.type === this.constructor.INTERSECTION_TYPES.OUT_IN) ^ clipType;
    const newPoly = new PIXI.Polygon();
    for ( let i = 0; i < ln; i += 1 ) {
      const ix = trackingArray[i];
      this.#processIntersection(ix, prevIx, wasTracingPolygon, newPoly);
      wasTracingPolygon = !wasTracingPolygon;
      prevIx = ix;
    }
    return [newPoly];
  }

  /* -------------------------------------------- */

  /**
   * Given an intersection and the previous intersection, fill the points
   * between the two intersections, in clockwise order.
   * @param {PolygonVertex} ix            Intersection to process
   * @param {PolygonVertex} prevIx        Previous intersection to process
   * @param {boolean} wasTracingPolygon   Whether we were tracing the polygon (true) or the clipObject (false).
   * @param {PIXI.Polygon} newPoly        The new polygon that results from this clipping operation
   */
  #processIntersection(ix, prevIx, wasTracingPolygon, newPoly) {
    const clipOpts = this.config.clipOpts;
    const pts = wasTracingPolygon ? ix.leadingPoints : this.clipObject.pointsBetween(prevIx, ix, clipOpts);
    for ( const pt of pts ) newPoly.addPoint(pt);
    newPoly.addPoint(ix);
  }

  /* -------------------------------------------- */

  /**
   * Test if one shape envelops the other. Assumes the shapes do not intersect.
   *  1. Polygon is contained within the clip object. Union: clip object; Intersect: polygon
   *  2. Clip object is contained with polygon. Union: polygon; Intersect: clip object
   *  3. Polygon and clip object are outside one another. Union: both; Intersect: null
   * @param {PIXI.Polygon} polygon                    Polygon to clip
   * @param {PIXI.Rectangle|PIXI.Circle} clipObject   Object to clip against the polygon
   * @param {WeilerAthertonClipper.CLIP_TYPES} clipType One of CLIP_TYPES
   * @param {object} clipOpts                         Clip options which are forwarded to toPolygon methods
   * @returns {PIXI.Polygon[]}  Returns the polygon, the clipObject.toPolygon(), both, or neither.
   */
  static testForEnvelopment(polygon, clipObject, clipType, clipOpts) {
    const points = polygon.points;
    if ( points.length < 6 ) return [];
    const union = clipType === this.CLIP_TYPES.UNION;

    // Option 1: Polygon contained within clipObject
    // We search for the first point of the polygon that is not on the boundary of the clip object.
    // One of these points can be used to determine whether the polygon is contained in the clip object.
    // If all points of the polygon are on the boundary of the clip object, which is either a circle
    // or a rectangle, then the polygon is contained within the clip object.
    let polygonInClipObject = true;
    for ( let i = 0; i < points.length; i += 2 ) {
      const point = { x: points[i], y: points[i + 1] };
      if ( !clipObject.pointIsOn(point) ) {
        polygonInClipObject = clipObject.contains(point.x, point.y);
        break;
      }
    }
    if ( polygonInClipObject ) return union ? [clipObject.toPolygon(clipOpts)] : [polygon];

    // Option 2: ClipObject contained within polygon
    const center = clipObject.center;

    // PointSourcePolygons need to have a bounds defined in order for polygon.contains to work.
    if ( polygon instanceof PointSourcePolygon ) polygon.bounds ??= polygon.getBounds();

    const clipObjectInPolygon = polygon.contains(center.x, center.y);
    if ( clipObjectInPolygon ) return union ? [polygon] : [clipObject.toPolygon(clipOpts)];

    // Option 3: Neither contains the other
    return union ? [polygon, clipObject.toPolygon(clipOpts)] : [];
  }

  /* -------------------------------------------- */

  /**
   * Construct an array of intersections between the polygon and the clipping object.
   * The intersections follow clockwise around the polygon.
   * Round all intersections and polygon vertices to the nearest pixel (integer).
   * @returns {Point[]}
   */
  #buildPointTrackingArray() {
    const labeledPoints = this.#buildIntersectionArray();
    if ( !labeledPoints.length ) return [];
    return WeilerAthertonClipper.#consolidatePoints(labeledPoints);
  }

  /* -------------------------------------------- */

  /**
   * Construct an array that holds all the points of the polygon with all the intersections with the clipObject
   * inserted, in correct position moving clockwise.
   * If an intersection and endpoint are nearly the same, prefer the intersection.
   * Intersections are labeled with isIntersection and type = out/in or in/out. Tangents are removed.
   * @returns {Point[]} Labeled array of points
   */
  #buildIntersectionArray() {
    const { polygon, clipObject } = this;
    const points = polygon.points;
    const ln = points.length;
    if ( ln < 6 ) return []; // Minimum 3 Points required

    // Need to start with a non-intersecting point on the polygon.
    let startIdx = -1;
    let a;
    for ( let i = 0; i < ln; i += 2 ) {
      a = { x: points[i], y: points[i + 1] };
      if ( !clipObject.pointIsOn(a) ) {
        startIdx = i;
        break;
      }
    }
    if ( !~startIdx ) return []; // All intersections, so all tangent

    // For each edge a|b, find the intersection point(s) with the clipObject.
    // Add intersections and endpoints to the pointsIxs array, taking care to avoid duplicating
    // points. For example, if the intersection equals a, add only the intersection, not both.
    let previousInside = clipObject.contains(a.x, a.y);
    let numPrevIx = 0;
    let lastIx = undefined;
    let secondLastIx = undefined;
    const pointsIxs = [a];
    const types = this.constructor.INTERSECTION_TYPES;
    const nIter = startIdx + ln + 2; // Add +2 to close the polygon.
    for ( let i = startIdx + 2; i < nIter; i += 2 ) {
      const j = i >= ln ? i % ln : i; // Circle back around the points as necessary.
      const b = { x: points[j], y: points[j + 1] };
      const ixs = clipObject.segmentIntersections(a, b);
      const ixsLn = ixs.length;
      let bIsIx = false;
      if ( ixsLn ) {
        bIsIx = b.x.almostEqual(ixs[ixsLn - 1].x) && b.y.almostEqual(ixs[ixsLn - 1].y);

        // If the intersection equals the current b, get that intersection next iteration.
        if ( bIsIx ) ixs.pop();

        // Determine whether the intersection is out-->in or in-->out
        numPrevIx += ixs.length;
        for ( const ix of ixs ) {
          ix.isIntersection = true;
          ix.type = lastIx ? -lastIx.type : previousInside ? types.IN_OUT : types.OUT_IN;
          secondLastIx = lastIx;
          lastIx = ix;
        }
        pointsIxs.push(...ixs);
      }

      // If b is an intersection, we will return to it next iteration.
      if ( bIsIx ) {
        a = b;
        continue;
      }

      // Each intersection represents a move across the clipObject border.
      // Count them and determine if we are now inside or outside the clipObject.
      if ( numPrevIx ) {
        const isInside = clipObject.contains(b.x, b.y);
        const changedSide = isInside ^ previousInside;
        const isOdd = numPrevIx & 1;

        // If odd number of intersections, should switch. e.g., outside --> ix --> inside
        // If even number of intersections, should stay same. e.g., outside --> ix --> ix --> outside.
        if ( isOdd ^ changedSide ) {
          if ( numPrevIx === 1 ) lastIx.isIntersection = false;
          else {
            secondLastIx.isIntersection = false;
            lastIx.type = secondLastIx.type;
          }
        }
        previousInside = isInside;
        numPrevIx = 0;
        secondLastIx = undefined;
        lastIx = undefined;
      }
      pointsIxs.push(b);
      a = b;
    }
    return pointsIxs;
  }

  /* -------------------------------------------- */

  /**
   * Given an array of labeled points, consolidate into a tracking array of intersections,
   * where each intersection contains its array of leadingPoints.
   * @param {Point[]} labeledPoints   Array of points, from _buildLabeledIntersectionsArray
   * @returns {Point[]} Array of intersections
   */
  static #consolidatePoints(labeledPoints) {

    // Locate the first intersection
    const startIxIdx = labeledPoints.findIndex(pt => pt.isIntersection);
    if ( !~startIxIdx ) return []; // No intersections, so no tracking array
    const labeledLn = labeledPoints.length;
    let leadingPoints = [];
    const trackingArray = [];

    // Closed polygon, so use the last point to circle back
    for ( let i = 0; i < labeledLn; i += 1 ) {
      const j = (i + startIxIdx) % labeledLn;
      const pt = labeledPoints[j];
      if ( pt.isIntersection ) {
        pt.leadingPoints = leadingPoints;
        leadingPoints = [];
        trackingArray.push(pt);
      } else leadingPoints.push(pt);
    }

    // Add leading points to first intersection
    trackingArray[0].leadingPoints = leadingPoints;
    return trackingArray;
  }
}

/**
 * The Drawing object is an implementation of the PlaceableObject container.
 * Each Drawing is a placeable object in the DrawingsLayer.
 *
 * @category - Canvas
 * @property {DrawingsLayer} layer                Each Drawing object belongs to the DrawingsLayer
 * @property {DrawingDocument} document           Each Drawing object provides an interface for a DrawingDocument
 */
class Drawing extends PlaceableObject {

  /**
   * The texture that is used to fill this Drawing, if any.
   * @type {PIXI.Texture}
   */
  texture;

  /**
   * The border frame and resizing handles for the drawing.
   * @type {PIXI.Container}
   */
  frame;

  /**
   * A text label that may be displayed as part of the interface layer for the Drawing.
   * @type {PreciseText|null}
   */
  text = null;

  /**
   * The drawing shape which is rendered as a PIXI.Graphics in the interface or a PrimaryGraphics in the Primary Group.
   * @type {PrimaryGraphics|PIXI.Graphics}
   */
  shape;

  /**
   * An internal timestamp for the previous freehand draw time, to limit sampling.
   * @type {number}
   */
  #drawTime = 0;

  /**
   * An internal flag for the permanent points of the polygon.
   * @type {number[]}
   */
  #fixedPoints = foundry.utils.deepClone(this.document.shape.points);

  /* -------------------------------------------- */

  /** @inheritdoc */
  static embeddedName = "Drawing";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshTransform", "refreshText", "refreshElevation"], alias: true},
    refreshState: {},
    refreshTransform: {propagate: ["refreshPosition", "refreshRotation", "refreshSize"], alias: true},
    refreshPosition: {},
    refreshRotation: {propagate: ["refreshFrame"]},
    refreshSize: {propagate: ["refreshPosition", "refreshFrame", "refreshShape", "refreshText"]},
    refreshShape: {},
    refreshText: {},
    refreshFrame: {},
    refreshElevation: {},
    /** @deprecated since v12 */
    refreshMesh: {
      propagate: ["refreshTransform", "refreshShape", "refreshElevation"],
      deprecated: {since: 12, until: 14, alias: true}
    }
  };

  /**
   * The rate at which points are sampled (in milliseconds) during a freehand drawing workflow
   * @type {number}
   */
  static FREEHAND_SAMPLE_RATE = 75;

  /**
   * A convenience reference to the possible shape types.
   * @enum {string}
   */
  static SHAPE_TYPES = foundry.data.ShapeData.TYPES;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A convenient reference for whether the current User is the author of the Drawing document.
   * @type {boolean}
   */
  get isAuthor() {
    return this.document.isAuthor;
  }

  /* -------------------------------------------- */

  /**
   * Is this Drawing currently visible on the Canvas?
   * @type {boolean}
   */
  get isVisible() {
    return !this.document.hidden || this.isAuthor || game.user.isGM || this.isPreview;
  }

  /* -------------------------------------------- */

  /** @override */
  get bounds() {
    const {x, y, shape, rotation} = this.document;
    return rotation === 0
      ? new PIXI.Rectangle(x, y, shape.width, shape.height).normalize()
      : PIXI.Rectangle.fromRotation(x, y, shape.width, shape.height, Math.toRadians(rotation)).normalize();
  }

  /* -------------------------------------------- */

  /** @override */
  get center() {
    const {x, y, shape} = this.document;
    return new PIXI.Point(x + (shape.width / 2), y + (shape.height / 2));
  }

  /* -------------------------------------------- */

  /**
   * A Boolean flag for whether the Drawing utilizes a tiled texture background?
   * @type {boolean}
   */
  get isTiled() {
    return this.document.fillType === CONST.DRAWING_FILL_TYPES.PATTERN;
  }

  /* -------------------------------------------- */

  /**
   * A Boolean flag for whether the Drawing is a Polygon type (either linear or freehand)?
   * @type {boolean}
   */
  get isPolygon() {
    return this.type === Drawing.SHAPE_TYPES.POLYGON;
  }

  /* -------------------------------------------- */

  /**
   * Does the Drawing have text that is displayed?
   * @type {boolean}
   */
  get hasText() {
    return ((this._pendingText !== undefined) || !!this.document.text) && (this.document.fontSize > 0);
  }

  /* -------------------------------------------- */

  /**
   * The shape type that this Drawing represents. A value in Drawing.SHAPE_TYPES.
   * @see {@link Drawing.SHAPE_TYPES}
   * @type {string}
   */
  get type() {
    return this.document.shape.type;
  }

  /* -------------------------------------------- */

  /**
   * The pending text.
   * @type {string}
   * @internal
   */
  _pendingText;

  /* -------------------------------------------- */

  /**
   * The registered keydown listener.
   * @type {Function|null}
   * @internal
   */
  _onkeydown = null;

  /* -------------------------------------------- */

  /**
   * Delete the Drawing if the text is empty once text editing ends?
   * @type {boolean}
   */
  #deleteIfEmptyText = false;

  /* -------------------------------------------- */
  /*  Initial Rendering                           */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _destroy(options) {
    this.#removeDrawing(this);
    this.texture?.destroy();
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    // Load the background texture, if one is defined
    const texture = this.document.texture;
    if ( this._original ) this.texture = this._original.texture?.clone();
    else this.texture = texture ? await loadTexture(texture, {fallback: "icons/svg/hazard.svg"}) : null;

    // Create the drawing container in the primary group or in the interface group
    this.shape = this.#addDrawing();
    this.shape.visible = true;

    // Control Border
    this.frame = this.addChild(this.#drawFrame());

    // Drawing text
    this.text = this.hasText ? this.shape.addChild(this.#drawText()) : null;

    // Interactivity
    this.cursor = this.document.isOwner ? "pointer" : null;
  }

  /* -------------------------------------------- */

  /**
   * Add a drawing object according to interface configuration.
   * @returns {PIXI.Graphics|PrimaryGraphics}
   */
  #addDrawing() {
    const targetGroup = this.document.interface ? canvas.interface : canvas.primary;
    const removeGroup = this.document.interface ? canvas.primary : canvas.interface;
    removeGroup.removeDrawing(this);
    return targetGroup.addDrawing(this);
  }

  /* -------------------------------------------- */

  /**
   * Remove a drawing object.
   */
  #removeDrawing() {
    canvas.interface.removeDrawing(this);
    canvas.primary.removeDrawing(this);
  }

  /* -------------------------------------------- */

  /**
   * Create elements for the Drawing border and handles
   * @returns {PIXI.Container}
   */
  #drawFrame() {
    const frame = new PIXI.Container();
    frame.eventMode = "passive";
    frame.bounds = new PIXI.Rectangle();
    frame.interaction = frame.addChild(new PIXI.Container());
    frame.interaction.hitArea = frame.bounds;
    frame.interaction.eventMode = "auto";
    frame.border = frame.addChild(new PIXI.Graphics());
    frame.border.eventMode = "none";
    frame.handle = frame.addChild(new ResizeHandle([1, 1]));
    frame.handle.eventMode = "static";
    return frame;
  }

  /* -------------------------------------------- */

  /**
   * Create a PreciseText element to be displayed as part of this drawing.
   * @returns {PreciseText}
   */
  #drawText() {
    const text = new PreciseText(this.document.text || "", this._getTextStyle());
    text.eventMode = "none";
    return text;
  }

  /* -------------------------------------------- */

  /**
   * Get the line style used for drawing the shape of this Drawing.
   * @returns {object}    The line style options (`PIXI.ILineStyleOptions`).
   * @protected
   */
  _getLineStyle() {
    const {strokeWidth, strokeColor, strokeAlpha} = this.document;
    return {width: strokeWidth, color: strokeColor, alpha: strokeAlpha};
  }

  /* -------------------------------------------- */

  /**
   * Get the fill style used for drawing the shape of this Drawing.
   * @returns {object}    The fill style options (`PIXI.IFillStyleOptions`).
   * @protected
   */
  _getFillStyle() {
    const {fillType, fillColor, fillAlpha} = this.document;
    const style = {color: fillColor, alpha: fillAlpha};
    if ( (fillType === CONST.DRAWING_FILL_TYPES.PATTERN) && this.texture?.valid ) style.texture = this.texture;
    else if ( !fillType ) style.alpha = 0;
    return style;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the text style used to instantiate a PIXI.Text or PreciseText instance for this Drawing document.
   * @returns {PIXI.TextStyle}
   * @protected
   */
  _getTextStyle() {
    const {fontSize, fontFamily, textColor, shape} = this.document;
    const stroke = Math.max(Math.round(fontSize / 32), 2);
    return PreciseText.getTextStyle({
      fontFamily: fontFamily,
      fontSize: fontSize,
      fill: textColor,
      strokeThickness: stroke,
      dropShadowBlur: Math.max(Math.round(fontSize / 16), 2),
      align: "center",
      wordWrap: true,
      wordWrapWidth: shape.width,
      padding: stroke * 4
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  clone() {
    const c = super.clone();
    c._pendingText = this._pendingText;
    return c;
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @override */
  _applyRenderFlags(flags) {
    if ( flags.refreshState ) this._refreshState();
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshRotation ) this._refreshRotation();
    if ( flags.refreshShape ) this._refreshShape();
    if ( flags.refreshText ) this._refreshText();
    if ( flags.refreshFrame ) this._refreshFrame();
    if ( flags.refreshElevation ) this._refreshElevation();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the position.
   * @protected
   */
  _refreshPosition() {
    const {x, y, shape: {width, height}} = this.document;
    if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
    this.position.set(x, y);
    this.shape.position.set(x + (width / 2), y + (height / 2));
    this.shape.pivot.set(width / 2, height / 2);
    if ( !this.text ) return;
    this.text.position.set(width / 2, height / 2);
    this.text.anchor.set(0.5, 0.5);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the rotation.
   * @protected
   */
  _refreshRotation() {
    const rotation = Math.toRadians(this.document.rotation);
    this.shape.rotation = rotation;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the displayed state of the Drawing.
   * Used to update aspects of the Drawing which change based on the user interaction state.
   * @protected
   */
  _refreshState() {
    const {hidden, locked, sort} = this.document;
    const wasVisible = this.visible;
    this.visible = this.isVisible;
    if ( this.visible !== wasVisible ) MouseInteractionManager.emulateMoveEvent();
    this.alpha = this._getTargetAlpha();
    const colors = CONFIG.Canvas.dispositionColors;
    this.frame.border.tint = this.controlled ? (locked ? colors.HOSTILE : colors.CONTROLLED) : colors.INACTIVE;
    this.frame.border.visible = this.controlled || this.hover || this.layer.highlightObjects;
    this.frame.handle.visible = this.controlled && !locked;
    this.zIndex = this.shape.zIndex = this.controlled ? 2 : this.hover ? 1 : 0;
    const oldEventMode = this.eventMode;
    this.eventMode = this.layer.active && (this.controlled || ["select", "text"].includes(game.activeTool)) ? "static" : "none";
    if ( this.eventMode !== oldEventMode ) MouseInteractionManager.emulateMoveEvent();
    this.shape.visible = this.visible;
    this.shape.sort = sort;
    this.shape.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.DRAWINGS;
    this.shape.alpha = this.alpha * (hidden ? 0.5 : 1);
    this.shape.hidden = hidden;
    if ( !this.text ) return;
    this.text.alpha = this.document.textAlpha;
  }

  /* -------------------------------------------- */

  /**
   * Clear and then draw the shape.
   * @protected
   */
  _refreshShape() {
    this.shape.clear();
    this.shape.lineStyle(this._getLineStyle());
    this.shape.beginTextureFill(this._getFillStyle());
    const lineWidth = this.shape.line.width;
    const shape = this.document.shape;
    switch ( shape.type ) {
      case Drawing.SHAPE_TYPES.RECTANGLE:
        this.shape.drawRect(
          lineWidth / 2,
          lineWidth / 2,
          Math.max(shape.width - lineWidth, 0),
          Math.max(shape.height - lineWidth, 0)
        );
        break;
      case Drawing.SHAPE_TYPES.ELLIPSE:
        this.shape.drawEllipse(
          shape.width / 2,
          shape.height / 2,
          Math.max(shape.width - lineWidth, 0) / 2,
          Math.max(shape.height - lineWidth, 0) / 2
        );
        break;
      case Drawing.SHAPE_TYPES.POLYGON:
        const isClosed = this.document.fillType || (shape.points.slice(0, 2).equals(shape.points.slice(-2)));
        if ( isClosed ) this.shape.drawSmoothedPolygon(shape.points, this.document.bezierFactor * 2);
        else this.shape.drawSmoothedPath(shape.points, this.document.bezierFactor * 2);
        break;
    }
    this.shape.endFill();
    this.shape.line.reset();
  }

  /* -------------------------------------------- */

  /**
   * Update sorting of this Drawing relative to other PrimaryCanvasGroup siblings.
   * Called when the elevation or sort order for the Drawing changes.
   * @protected
   */
  _refreshElevation() {
    this.shape.elevation = this.document.elevation;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the border frame that encloses the Drawing.
   * @protected
   */
  _refreshFrame() {
    const thickness = CONFIG.Canvas.objectBorderThickness;

    // Update the frame bounds
    const {shape: {width, height}, rotation} = this.document;
    const bounds = this.frame.bounds;
    bounds.x = 0;
    bounds.y = 0;
    bounds.width = width;
    bounds.height = height;
    bounds.rotate(Math.toRadians(rotation));
    const minSize = thickness * 0.25;
    if ( bounds.width < minSize ) {
      bounds.x -= ((minSize - bounds.width) / 2);
      bounds.width = minSize;
    }
    if ( bounds.height < minSize ) {
      bounds.y -= ((minSize - bounds.height) / 2);
      bounds.height = minSize;
    }
    MouseInteractionManager.emulateMoveEvent();

    // Draw the border
    const border = this.frame.border;
    border.clear();
    border.lineStyle({width: thickness, color: 0x000000, join: PIXI.LINE_JOIN.ROUND, alignment: 0.75})
      .drawShape(bounds);
    border.lineStyle({width: thickness / 2, color: 0xFFFFFF, join: PIXI.LINE_JOIN.ROUND, alignment: 1})
      .drawShape(bounds);

    // Draw the handle
    this.frame.handle.refresh(bounds);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the content and appearance of text.
   * @protected
   */
  _refreshText() {
    if ( !this.text ) return;
    const {text, textAlpha} = this.document;
    this.text.text = this._pendingText ?? text ?? "";
    this.text.alpha = textAlpha;
    this.text.style = this._getTextStyle();
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /**
   * Add a new polygon point to the drawing, ensuring it differs from the last one
   * @param {Point} position            The drawing point to add
   * @param {object} [options]          Options which configure how the point is added
   * @param {boolean} [options.round=false]     Should the point be rounded to integer coordinates?
   * @param {boolean} [options.snap=false]      Should the point be snapped to grid precision?
   * @param {boolean} [options.temporary=false] Is this a temporary control point?
   * @internal
   */
  _addPoint(position, {round=false, snap=false, temporary=false}={}) {
    if ( snap ) position = this.layer.getSnappedPoint(position);
    if ( round ) {
      position.x = Math.round(position.x);
      position.y = Math.round(position.y);
    }

    // Avoid adding duplicate points
    const last = this.#fixedPoints.slice(-2);
    const next = [position.x - this.document.x, position.y - this.document.y];
    if ( next.equals(last) ) return;

    // Append the new point and update the shape
    const points = this.#fixedPoints.concat(next);
    this.document.shape.updateSource({points});
    if ( !temporary ) {
      this.#fixedPoints = points;
      this.#drawTime = Date.now();
    }
  }

  /* -------------------------------------------- */

  /**
   * Remove the last fixed point from the polygon
   * @internal
   */
  _removePoint() {
    this.#fixedPoints.splice(-2);
    this.document.shape.updateSource({points: this.#fixedPoints});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onControl(options) {
    super._onControl(options);
    this.enableTextEditing(options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRelease(options) {
    super._onRelease(options);
    if ( this._onkeydown ) {
      document.removeEventListener("keydown", this._onkeydown);
      this._onkeydown = null;
    }
    if ( canvas.scene.drawings.has(this.id) ) {
      if ( (this._pendingText === "") && this.#deleteIfEmptyText ) this.document.delete();
      else if ( this._pendingText !== undefined ) {    // Submit pending text
        this.#deleteIfEmptyText = false;
        this.document.update({text: this._pendingText}).then(() => {
          this._pendingText = undefined;
          this.renderFlags.set({redraw: this.hasText === !this.text, refreshText: true});
        });
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _overlapsSelection(rectangle) {
    if ( !this.frame ) return false;
    const localRectangle = new PIXI.Rectangle(
      rectangle.x - this.document.x,
      rectangle.y - this.document.y,
      rectangle.width,
      rectangle.height
    );
    return localRectangle.overlaps(this.frame.bounds);
  }

  /* -------------------------------------------- */

  /**
   * Enable text editing for this drawing.
   * @param {object} [options]
   */
  enableTextEditing(options={}) {
    if ( (game.activeTool === "text") || options.forceTextEditing ) {
      this._pendingText = this.document.text || "";
      this._onkeydown = this.#onDrawingTextKeydown.bind(this);
      document.addEventListener("keydown", this._onkeydown);
      if ( options.isNew ) this.#deleteIfEmptyText = true;
      this.renderFlags.set({refreshPosition: !this.text, refreshText: true});
      this.text ??= this.shape.addChild(this.#drawText());
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle text entry in an active text tool
   * @param {KeyboardEvent} event
   */
  #onDrawingTextKeydown(event) {

    // Ignore events when an input is focused, or when ALT or CTRL modifiers are applied
    if ( event.altKey || event.ctrlKey || event.metaKey ) return;
    if ( game.keyboard.hasFocus ) return;

    // Track refresh or conclusion conditions
    let conclude = false;
    let refresh = false;

    // Enter (submit) or Escape (cancel)
    if ( ["Escape", "Enter"].includes(event.key) ) {
      conclude = true;
    }

    // Deleting a character
    else if ( event.key === "Backspace" ) {
      this._pendingText = this._pendingText.slice(0, -1);
      refresh = true;
    }

    // Typing text (any single char)
    else if ( /^.$/.test(event.key) ) {
      this._pendingText += event.key;
      refresh = true;
    }

    // Stop propagation if the event was handled
    if ( refresh || conclude ) {
      event.preventDefault();
      event.stopPropagation();
    }

    // Conclude the workflow
    if ( conclude ) {
      this.release();
    }

    // Refresh the display
    else if ( refresh ) {
      this.renderFlags.set({refreshText: true});
    }
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Update pending text
    if ( ("text" in changed) && (this._pendingText !== undefined) ) this._pendingText = this.document.text || "";

    // Sort the interface drawings container if necessary
    if ( this.shape?.parent && (("elevation" in changed) || ("sort" in changed)) ) this.shape.parent.sortDirty = true;

    // Refresh the Tile
    this.renderFlags.set({
      redraw: ("interface" in changed) || ("texture" in changed) || (("text" in changed) && (this.hasText === !this.text)),
      refreshState: ("sort" in changed) || ("hidden" in changed) || ("locked" in changed),
      refreshPosition: ("x" in changed) || ("y" in changed),
      refreshRotation: "rotation" in changed,
      refreshSize: ("shape" in changed) && (("width" in changed.shape) || ("height" in changed.shape)),
      refreshElevation: "elevation" in changed,
      refreshShape: ["shape", "bezierFactor", "strokeWidth", "strokeColor", "strokeAlpha",
        "fillType", "fillColor", "fillAlpha"].some(k => k in changed),
      refreshText: ["text", "fontFamily", "fontSize", "textColor", "textAlpha"].some(k => k in changed)
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( this._onkeydown ) document.removeEventListener("keydown", this._onkeydown);
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners() {
    super.activateListeners();
    this.frame.handle.off("pointerover").off("pointerout")
      .on("pointerover", this._onHandleHoverIn.bind(this))
      .on("pointerout", this._onHandleHoverOut.bind(this));
  }

  /* -------------------------------------------- */

  /** @override */
  _canControl(user, event) {
    if ( !this.layer.active || this.isPreview ) return false;
    if ( this._creating ) {  // Allow one-time control immediately following creation
      delete this._creating;
      return true;
    }
    if ( this.controlled ) return true;
    if ( !["select", "text"].includes(game.activeTool) ) return false;
    return user.isGM || (user === this.document.author);
  }

  /* -------------------------------------------- */

  /** @override */
  _canConfigure(user, event) {
    return this.controlled;
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse movement which modifies the dimensions of the drawn shape.
   * @param {PIXI.FederatedEvent} event
   * @protected
   */
  _onMouseDraw(event) {
    const {destination, origin} = event.interactionData;
    const isShift = event.shiftKey;
    const isAlt = event.altKey;
    let position = destination;

    // Drag differently depending on shape type
    switch ( this.type ) {

      // Polygon Shapes
      case Drawing.SHAPE_TYPES.POLYGON:
        const isFreehand = game.activeTool === "freehand";
        let temporary = true;
        if ( isFreehand ) {
          const now = Date.now();
          temporary = (now - this.#drawTime) < this.constructor.FREEHAND_SAMPLE_RATE;
        }
        const snap = !(isShift || isFreehand);
        this._addPoint(position, {snap, temporary});
        break;

      // Other Shapes
      default:
        if ( !isShift ) position = this.layer.getSnappedPoint(position);
        const shape = this.document.shape;
        const minSize = canvas.dimensions.size * 0.5;
        let dx = position.x - origin.x;
        let dy = position.y - origin.y;
        if ( Math.abs(dx) < minSize ) dx = minSize * Math.sign(shape.width);
        if ( Math.abs(dy) < minSize ) dy = minSize * Math.sign(shape.height);
        if ( isAlt ) {
          dx = Math.abs(dy) < Math.abs(dx) ? Math.abs(dy) * Math.sign(dx) : dx;
          dy = Math.abs(dx) < Math.abs(dy) ? Math.abs(dx) * Math.sign(dy) : dy;
        }
        const r = new PIXI.Rectangle(origin.x, origin.y, dx, dy).normalize();
        this.document.updateSource({
          x: r.x,
          y: r.y,
          shape: {
            width: r.width,
            height: r.height
          }
        });
        break;
    }

    // Refresh the display
    this.renderFlags.set({refreshPosition: true, refreshSize: true});
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _onClickLeft(event) {
    if ( event.target === this.frame.handle ) {
      event.interactionData.dragHandle = true;
      event.stopPropagation();
      return;
    }
    return super._onClickLeft(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftStart(event) {
    if ( event.interactionData.dragHandle ) return this._onHandleDragStart(event);
    return super._onDragLeftStart(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftMove(event) {
    if ( event.interactionData.dragHandle ) return this._onHandleDragMove(event);
    return super._onDragLeftMove(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftDrop(event) {
    if ( event.interactionData.dragHandle ) return this._onHandleDragDrop(event);
    return super._onDragLeftDrop(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragLeftCancel(event) {
    if ( event.interactionData.dragHandle ) return this._onHandleDragCancel(event);
    return super._onDragLeftCancel(event);
  }

  /* -------------------------------------------- */
  /*  Resize Handling                             */
  /* -------------------------------------------- */

  /**
   * Handle mouse-over event on a control handle
   * @param {PIXI.FederatedEvent} event   The mouseover event
   * @protected
   */
  _onHandleHoverIn(event) {
    const handle = event.target;
    handle?.scale.set(1.5, 1.5);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-out event on a control handle
   * @param {PIXI.FederatedEvent} event   The mouseout event
   * @protected
   */
  _onHandleHoverOut(event) {
    const handle = event.target;
    handle?.scale.set(1.0, 1.0);
  }

  /* -------------------------------------------- */

  /**
   * Starting the resize handle drag event, initialize the original data.
   * @param {PIXI.FederatedEvent} event   The mouse interaction event
   * @protected
   */
  _onHandleDragStart(event) {
    event.interactionData.originalData = this.document.toObject();
    const handle = this.frame.handle;
    event.interactionData.handleOrigin = {x: handle.position.x, y: handle.position.y};
  }

  /* -------------------------------------------- */

  /**
   * Handle mousemove while dragging a tile scale handler
   * @param {PIXI.FederatedEvent} event   The mouse interaction event
   * @protected
   */
  _onHandleDragMove(event) {

    // Pan the canvas if the drag event approaches the edge
    canvas._onDragCanvasPan(event);

    // Update Drawing dimensions
    const {destination, origin, handleOrigin, originalData} = event.interactionData;
    let handleDestination = {
      x: handleOrigin.x + (destination.x - origin.x),
      y: handleOrigin.y + (destination.y - origin.y)
    };
    if ( !event.shiftKey ) handleDestination = this.layer.getSnappedPoint(handleDestination);
    const dx = handleDestination.x - handleOrigin.x;
    const dy = handleDestination.y - handleOrigin.y;
    const normalized = Drawing.rescaleDimensions(originalData, dx, dy);

    // Update the drawing, catching any validation failures
    this.document.updateSource(normalized);
    this.document.rotation = 0;
    this.renderFlags.set({refreshTransform: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle mouseup after dragging a tile scale handler
   * @param {PIXI.FederatedEvent} event   The mouseup event
   * @protected
   */
  _onHandleDragDrop(event) {
    event.interactionData.restoreOriginalData = false;
    const {destination, origin, handleOrigin, originalData} = event.interactionData;
    let handleDestination = {
      x: handleOrigin.x + (destination.x - origin.x),
      y: handleOrigin.y + (destination.y - origin.y)
    };
    if ( !event.shiftKey ) handleDestination = this.layer.getSnappedPoint(handleDestination);
    const dx = handleDestination.x - handleOrigin.x;
    const dy = handleDestination.y - handleOrigin.y;
    const update = Drawing.rescaleDimensions(originalData, dx, dy);
    this.document.update(update, {diff: false})
      .then(() => this.renderFlags.set({refreshTransform: true}));
  }

  /* -------------------------------------------- */

  /**
   * Handle cancellation of a drag event for one of the resizing handles
   * @param {PointerEvent} event            The drag cancellation event
   * @protected
   */
  _onHandleDragCancel(event) {
    if ( event.interactionData.restoreOriginalData !== false ) {
      this.document.updateSource(event.interactionData.originalData);
      this.renderFlags.set({refreshTransform: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Get a vectorized rescaling transformation for drawing data and dimensions passed in parameter
   * @param {Object} original     The original drawing data
   * @param {number} dx           The pixel distance dragged in the horizontal direction
   * @param {number} dy           The pixel distance dragged in the vertical direction
   * @returns {object}            The adjusted shape data
   */
  static rescaleDimensions(original, dx, dy) {
    let {type, points, width, height} = original.shape;
    width += dx;
    height += dy;
    points = points || [];

    // Rescale polygon points
    if ( type === Drawing.SHAPE_TYPES.POLYGON ) {
      const scaleX = 1 + (original.shape.width > 0 ? dx / original.shape.width : 0);
      const scaleY = 1 + (original.shape.height > 0 ? dy / original.shape.height : 0);
      points = points.map((p, i) => p * (i % 2 ? scaleY : scaleX));
    }

    // Normalize the shape
    return this.normalizeShape({
      x: original.x,
      y: original.y,
      shape: {width: Math.round(width), height: Math.round(height), points}
    });
  }

  /* -------------------------------------------- */

  /**
   * Adjust the location, dimensions, and points of the Drawing before committing the change.
   * @param {object} data   The DrawingData pending update
   * @returns {object}      The adjusted data
   */
  static normalizeShape(data) {

    // Adjust shapes with an explicit points array
    const rawPoints = data.shape.points;
    if ( rawPoints?.length ) {

      // Organize raw points and de-dupe any points which repeated in sequence
      const xs = [];
      const ys = [];
      for ( let i=1; i<rawPoints.length; i+=2 ) {
        const x0 = rawPoints[i-3];
        const y0 = rawPoints[i-2];
        const x1 = rawPoints[i-1];
        const y1 = rawPoints[i];
        if ( (x1 === x0) && (y1 === y0) ) {
          continue;
        }
        xs.push(x1);
        ys.push(y1);
      }

      // Determine minimal and maximal points
      const minX = Math.min(...xs);
      const maxX = Math.max(...xs);
      const minY = Math.min(...ys);
      const maxY = Math.max(...ys);

      // Normalize points relative to minX and minY
      const points = [];
      for ( let i=0; i<xs.length; i++ ) {
        points.push(xs[i] - minX, ys[i] - minY);
      }

      // Update data
      data.x += minX;
      data.y += minY;
      data.shape.width = maxX - minX;
      data.shape.height = maxY - minY;
      data.shape.points = points;
    }

    // Adjust rectangles
    else {
      const normalized = new PIXI.Rectangle(data.x, data.y, data.shape.width, data.shape.height).normalize();
      data.x = normalized.x;
      data.y = normalized.y;
      data.shape.width = normalized.width;
      data.shape.height = normalized.height;
    }
    return data;
  }
}

/**
 * An AmbientLight is an implementation of PlaceableObject which represents a dynamic light source within the Scene.
 * @category - Canvas
 * @see {@link AmbientLightDocument}
 * @see {@link LightingLayer}
 */
class AmbientLight extends PlaceableObject {
  /**
   * The area that is affected by this light.
   * @type {PIXI.Graphics}
   */
  field;

  /**
   * A reference to the PointSource object which defines this light or darkness area of effect.
   * This is undefined if the AmbientLight does not provide an active source of light.
   * @type {PointDarknessSource|PointLightSource}
   */
  lightSource;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static embeddedName = "AmbientLight";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshField", "refreshElevation"], alias: true},
    refreshField: {propagate: ["refreshPosition"]},
    refreshPosition: {},
    refreshState: {},
    refreshElevation: {}
  };

  /* -------------------------------------------- */

  /** @inheritdoc */
  get bounds() {
    const {x, y} = this.document;
    const r = Math.max(this.dimRadius, this.brightRadius);
    return new PIXI.Rectangle(x-r, y-r, 2*r, 2*r);
  }

  /* -------------------------------------------- */

  /** @override */
  get sourceId() {
    let id = `${this.document.documentName}.${this.document.id}`;
    if ( this.isPreview ) id += ".preview";
    return id;
  }

  /* -------------------------------------------- */

  /**
   * A convenience accessor to the LightData configuration object
   * @returns {LightData}
   */
  get config() {
    return this.document.config;
  }

  /* -------------------------------------------- */

  /**
   * Test whether a specific AmbientLight source provides global illumination
   * @type {boolean}
   */
  get global() {
    return this.document.isGlobal;
  }

  /* -------------------------------------------- */

  /**
   * The maximum radius in pixels of the light field
   * @type {number}
   */
  get radius() {
    return Math.max(Math.abs(this.dimRadius), Math.abs(this.brightRadius));
  }

  /* -------------------------------------------- */

  /**
   * Get the pixel radius of dim light emitted by this light source
   * @type {number}
   */
  get dimRadius() {
    let d = canvas.dimensions;
    return ((this.config.dim / d.distance) * d.size);
  }

  /* -------------------------------------------- */

  /**
   * Get the pixel radius of bright light emitted by this light source
   * @type {number}
   */
  get brightRadius() {
    let d = canvas.dimensions;
    return ((this.config.bright / d.distance) * d.size);
  }

  /* -------------------------------------------- */

  /**
   * Is this Ambient Light currently visible? By default, true only if the source actively emits light or darkness.
   * @type {boolean}
   */
  get isVisible() {
    return !this._isLightSourceDisabled();
  }

  /* -------------------------------------------- */

  /**
   * Check if the point source is a LightSource instance
   * @type {boolean}
   */
  get isLightSource() {
    return this.lightSource instanceof CONFIG.Canvas.lightSourceClass;
  }

  /* -------------------------------------------- */

  /**
   * Check if the point source is a DarknessSource instance
   * @type {boolean}
   */
  get isDarknessSource() {
    return this.lightSource instanceof CONFIG.Canvas.darknessSourceClass;
  }

  /* -------------------------------------------- */

  /**
   * Is the source of this Ambient Light disabled?
   * @type {boolean}
   * @protected
   */
  _isLightSourceDisabled() {
    const {hidden, config} = this.document;

    // Hidden lights are disabled
    if ( hidden ) return true;

    // Lights with zero radius or angle are disabled
    if ( !(this.radius && config.angle) ) return true;

    // If the darkness level is outside of the darkness activation range, the light is disabled
    const darkness = canvas.darknessLevel;
    return !darkness.between(config.darkness.min, config.darkness.max);
  }

  /* -------------------------------------------- */

  /**
   * Does this Ambient Light actively emit darkness light given
   * its properties and the current darkness level of the Scene?
   * @type {boolean}
   */
  get emitsDarkness() {
    return this.document.config.negative && !this._isLightSourceDisabled();
  }

  /* -------------------------------------------- */

  /**
   * Does this Ambient Light actively emit positive light given
   * its properties and the current darkness level of the Scene?
   * @type {boolean}
   */
  get emitsLight() {
    return !this.document.config.negative && !this._isLightSourceDisabled();
  }

  /* -------------------------------------------- */
  /* Rendering
  /* -------------------------------------------- */

  /** @override */
  _destroy(options) {
    this.#destroyLightSource();
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.field = this.addChild(new PIXI.Graphics());
    this.field.eventMode = "none";
    this.controlIcon = this.addChild(this.#drawControlIcon());
    this.initializeLightSource();
  }

  /* -------------------------------------------- */

  /**
   * Draw the ControlIcon for the AmbientLight
   * @returns {ControlIcon}
   */
  #drawControlIcon() {
    const size = Math.max(Math.round((canvas.dimensions.size * 0.5) / 20) * 20, 40);
    let icon = new ControlIcon({texture: CONFIG.controlIcons.light, size: size });
    icon.x -= (size * 0.5);
    icon.y -= (size * 0.5);
    return icon;
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @override */
  _applyRenderFlags(flags) {
    if ( flags.refreshState ) this._refreshState();
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshField ) this._refreshField();
    if ( flags.refreshElevation ) this._refreshElevation();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the shape of the light field-of-effect. This is refreshed when the AmbientLight fov polygon changes.
   * @protected
   */
  _refreshField() {
    this.field.clear();
    if ( !this.lightSource?.shape ) return;
    this.field.lineStyle(2, 0xEEEEEE, 0.4).drawShape(this.lightSource.shape);
    this.field.position.set(-this.lightSource.x, -this.lightSource.y);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the position of the AmbientLight. Called with the coordinates change.
   * @protected
   */
  _refreshPosition() {
    const {x, y} = this.document;
    if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
    this.position.set(x, y);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the elevation of the control icon.
   * @protected
   */
  _refreshElevation() {
    this.controlIcon.elevation = this.document.elevation;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the state of the light. Called when the disabled state or darkness conditions change.
   * @protected
   */
  _refreshState() {
    this.alpha = this._getTargetAlpha();
    this.zIndex = this.hover ? 1 : 0;
    this.refreshControl();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the display of the ControlIcon for this AmbientLight source.
   */
  refreshControl() {
    const isHidden = this.id && this.document.hidden;
    this.controlIcon.texture = getTexture(this.isVisible ? CONFIG.controlIcons.light : CONFIG.controlIcons.lightOff);
    this.controlIcon.tintColor = isHidden ? 0xFF3300 : 0xFFFFFF;
    this.controlIcon.borderColor = isHidden ? 0xFF3300 : 0xFF5500;
    this.controlIcon.elevation = this.document.elevation;
    this.controlIcon.refresh({visible: this.layer.active, borderVisible: this.hover || this.layer.highlightObjects});
    this.controlIcon.draw();
  }

  /* -------------------------------------------- */
  /*  Light Source Management                     */
  /* -------------------------------------------- */

  /**
   * Update the LightSource associated with this AmbientLight object.
   * @param {object} [options={}]               Options which modify how the source is updated
   * @param {boolean} [options.deleted=false]   Indicate that this light source has been deleted
   */
  initializeLightSource({deleted=false}={}) {
    const sourceId = this.sourceId;
    const wasLight = canvas.effects.lightSources.has(sourceId);
    const wasDarkness = canvas.effects.darknessSources.has(sourceId);
    const isDarkness = this.document.config.negative;
    const perceptionFlags = {
      refreshEdges: wasDarkness || isDarkness,
      initializeVision: wasDarkness || isDarkness,
      initializeLighting: wasDarkness || isDarkness,
      refreshLighting: true,
      refreshVision: true
    };

    // Remove the light source from the active collection
    if ( deleted ) {
      if ( !this.lightSource?.active ) return;
      this.#destroyLightSource();
      canvas.perception.update(perceptionFlags);
      return;
    }

    // Re-create source if it switches darkness state
    if ( (wasLight && isDarkness) || (wasDarkness && !isDarkness) ) this.#destroyLightSource();

    // Create the light source if necessary
    this.lightSource ??= this.#createLightSource();

    // Re-initialize source data and add to the active collection
    this.lightSource.initialize(this._getLightSourceData());
    this.lightSource.add();

    // Assign perception and render flags
    canvas.perception.update(perceptionFlags);
    if ( this.layer.active ) this.renderFlags.set({refreshField: true});
  }

  /* -------------------------------------------- */

  /**
   * Get the light source data.
   * @returns {LightSourceData}
   * @protected
   */
  _getLightSourceData() {
    const {x, y, elevation, rotation, walls, vision} = this.document;
    const d = canvas.dimensions;
    return foundry.utils.mergeObject(this.config.toObject(false), {
      x, y, elevation, rotation, walls, vision,
      dim: Math.clamp(this.dimRadius, 0, d.maxR),
      bright: Math.clamp(this.brightRadius, 0, d.maxR),
      seed: this.document.getFlag("core", "animationSeed"),
      disabled: this._isLightSourceDisabled(),
      preview: this.isPreview
    });
  }

  /* -------------------------------------------- */

  /**
   * Returns a new point source: DarknessSource or LightSource, depending on the config data.
   * @returns {foundry.canvas.sources.PointLightSource|foundry.canvas.sources.PointDarknessSource} The created source
   */
  #createLightSource() {
    const sourceClass = this.config.negative ? CONFIG.Canvas.darknessSourceClass : CONFIG.Canvas.lightSourceClass;
    const sourceId = this.sourceId;
    return new sourceClass({sourceId, object: this});
  }

  /* -------------------------------------------- */

  /**
   * Destroy the existing BaseEffectSource instance for this AmbientLight.
   */
  #destroyLightSource() {
    this.lightSource?.destroy();
    this.lightSource = undefined;
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    this.initializeLightSource();
  }

  /* -------------------------------------------- */

  /** @override */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    this.initializeLightSource();
    this.renderFlags.set({
      refreshState: ("hidden" in changed) || (("config" in changed)
        && ["dim", "bright", "angle", "darkness"].some(k => k in changed.config)),
      refreshElevation: "elevation" in changed
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    this.initializeLightSource({deleted: true});
    super._onDelete(options, userId);
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _canHUD(user, event) {
    return user.isGM; // Allow GMs to single right-click
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _canConfigure(user, event) {
    return false; // Double-right does nothing
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _canDragLeftStart(user, event) {
    // Prevent dragging another light if currently previewing one.
    if ( this.layer?.preview?.children.length ) {
      ui.notifications.warn("CONTROLS.ObjectConfigured", { localize: true });
      return false;
    }
    return super._canDragLeftStart(user, event);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onClickRight(event) {
    this.document.update({hidden: !this.document.hidden});
    if ( !this._propagateRightClick(event) ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftMove(event) {
    super._onDragLeftMove(event);
    this.initializeLightSource({deleted: true});
    const clones = event.interactionData.clones || [];
    for ( const c of clones ) c.initializeLightSource();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragEnd() {
    this.initializeLightSource({deleted: true});
    this._original?.initializeLightSource();
    super._onDragEnd();
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  updateSource({deleted=false}={}) {
    const msg = "AmbientLight#updateSource has been deprecated in favor of AmbientLight#initializeLightSource";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    this.initializeLightSource({deleted});
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get source() {
    const msg = "AmbientLight#source has been deprecated in favor of AmbientLight#lightSource";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.lightSource;
  }
}

/**
 * A Note is an implementation of PlaceableObject which represents an annotated location within the Scene.
 * Each Note links to a JournalEntry document and represents its location on the map.
 * @category - Canvas
 * @see {@link NoteDocument}
 * @see {@link NotesLayer}
 */
class Note extends PlaceableObject {

  /** @inheritdoc */
  static embeddedName = "Note";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshPosition", "refreshTooltip", "refreshElevation"], alias: true},
    refreshState: {propagate: ["refreshVisibility"]},
    refreshVisibility: {},
    refreshPosition: {},
    refreshTooltip: {},
    refreshElevation: {propagate: ["refreshVisibility"]},
    /** @deprecated since v12 */
    refreshText: {propagate: ["refreshTooltip"], deprecated: {since: 12, until: 14}, alias: true}
  };

  /* -------------------------------------------- */

  /**
   * The control icon.
   * @type {ControlIcon}
   */
  controlIcon;

  /* -------------------------------------------- */

  /**
   * The tooltip.
   * @type {PreciseText}
   */
  tooltip;

  /* -------------------------------------------- */

  /** @override */
  get bounds() {
    const {x, y, iconSize} = this.document;
    const r = iconSize / 2;
    return new PIXI.Rectangle(x - r, y - r, 2*r, 2*r);
  }

  /* -------------------------------------------- */

  /**
   * The associated JournalEntry which is referenced by this Note
   * @type {JournalEntry}
   */
  get entry() {
    return this.document.entry;
  }

  /* -------------------------------------------- */

  /**
   * The specific JournalEntryPage within the associated JournalEntry referenced by this Note.
   */
  get page() {
    return this.document.page;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the Note is visible to the current user based on their perspective of the Scene.
   * Visibility depends on permission to the underlying journal entry, as well as the perspective of controlled Tokens.
   * If Token Vision is required, the user must have a token with vision over the note to see it.
   * @type {boolean}
   */
  get isVisible() {
    const accessTest = this.document.page ?? this.document.entry;
    const access = accessTest?.testUserPermission(game.user, "LIMITED") ?? true;
    if ( (access === false) || !canvas.visibility.tokenVision || this.document.global ) return access;
    const point = {x: this.document.x, y: this.document.y};
    const tolerance = this.document.iconSize / 4;
    return canvas.visibility.testVisibility(point, {tolerance, object: this});
  }

  /* -------------------------------------------- */
  /* Rendering
  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.controlIcon = this.addChild(this._drawControlIcon());
    this.tooltip = this.addChild(this._drawTooltip());
  }

  /* -------------------------------------------- */

  /**
   * Draw the control icon.
   * @returns {ControlIcon}
   * @protected
   */
  _drawControlIcon() {
    const {texture, iconSize} = this.document;
    const icon = new ControlIcon({texture: texture.src, size: iconSize, tint: texture.tint});
    icon.x -= (iconSize / 2);
    icon.y -= (iconSize / 2);
    return icon;
  }

  /* -------------------------------------------- */

  /**
   * Draw the tooltip.
   * @returns {PreciseText}
   * @protected
   */
  _drawTooltip() {
    const tooltip = new PreciseText(this.document.label, this._getTextStyle());
    tooltip.eventMode = "none";
    return tooltip;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the tooltip.
   * @protected
   */
  _refreshTooltip() {
    this.tooltip.text = this.document.label;
    this.tooltip.style = this._getTextStyle();
    const halfPad = (0.5 * this.document.iconSize) + 12;
    switch ( this.document.textAnchor ) {
      case CONST.TEXT_ANCHOR_POINTS.CENTER:
        this.tooltip.anchor.set(0.5, 0.5);
        this.tooltip.position.set(0, 0);
        break;
      case CONST.TEXT_ANCHOR_POINTS.BOTTOM:
        this.tooltip.anchor.set(0.5, 0);
        this.tooltip.position.set(0, halfPad);
        break;
      case CONST.TEXT_ANCHOR_POINTS.TOP:
        this.tooltip.anchor.set(0.5, 1);
        this.tooltip.position.set(0, -halfPad);
        break;
      case CONST.TEXT_ANCHOR_POINTS.LEFT:
        this.tooltip.anchor.set(1, 0.5);
        this.tooltip.position.set(-halfPad, 0);
        break;
      case CONST.TEXT_ANCHOR_POINTS.RIGHT:
        this.tooltip.anchor.set(0, 0.5);
        this.tooltip.position.set(halfPad, 0);
        break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Define a PIXI TextStyle object which is used for the tooltip displayed for this Note
   * @returns {PIXI.TextStyle}
   * @protected
   */
  _getTextStyle() {
    const style = CONFIG.canvasTextStyle.clone();

    // Positioning
    if ( this.document.textAnchor === CONST.TEXT_ANCHOR_POINTS.LEFT ) style.align = "right";
    else if ( this.document.textAnchor === CONST.TEXT_ANCHOR_POINTS.RIGHT ) style.align = "left";

    // Font preferences
    style.fontFamily = this.document.fontFamily || CONFIG.defaultFontFamily;
    style.fontSize = this.document.fontSize;

    // Toggle stroke style depending on whether the text color is dark or light
    const color = this.document.textColor;
    style.fill = color;
    style.stroke = color.hsv[2] > 0.6 ? 0x000000 : 0xFFFFFF;
    style.strokeThickness = 4;
    return style;
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @override */
  _applyRenderFlags(flags) {
    if ( flags.refreshState ) this._refreshState();
    if ( flags.refreshVisibility ) this._refreshVisibility();
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshTooltip ) this._refreshTooltip();
    if ( flags.refreshElevation ) this._refreshElevation();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the visibility.
   * @protected
   */
  _refreshVisibility() {
    const wasVisible = this.visible;
    this.visible = this.isVisible;
    if ( this.controlIcon ) this.controlIcon.refresh({
      visible: this.visible,
      borderVisible: this.hover || this.layer.highlightObjects
    });
    if ( wasVisible !== this.visible ) {
      this.layer.hintMapNotes();
      MouseInteractionManager.emulateMoveEvent();
    }
  }

  /* -------------------------------------------- */

  /**
   * Refresh the state of the Note. Called the Note enters a different interaction state.
   * @protected
   */
  _refreshState() {
    this.alpha = this._getTargetAlpha();
    this.tooltip.visible = this.hover || this.layer.highlightObjects;
    this.zIndex = this.hover ? 1 : 0;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the position of the Note. Called with the coordinates change.
   * @protected
   */
  _refreshPosition() {
    const {x, y} = this.document;
    if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
    this.position.set(this.document.x, this.document.y);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the elevation of the control icon.
   * @protected
   */
  _refreshElevation() {
    this.controlIcon.elevation = this.document.elevation;
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Incremental Refresh
    const positionChanged = ("x" in changed) || ("y" in changed);
    this.renderFlags.set({
      redraw: ("texture" in changed) || ("iconSize" in changed),
      refreshVisibility: positionChanged || ["entryId", "pageId", "global"].some(k => k in changed),
      refreshPosition: positionChanged,
      refreshTooltip: ["text", "fontFamily", "fontSize", "textAnchor", "textColor", "iconSize"].some(k => k in changed),
      refreshElevation: "elevation" in changed
    });
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @override */
  _canHover(user) {
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  _canView(user) {
    const {entry, page} = this.document;
    if ( !entry ) return false;
    if ( game.user.isGM ) return true;
    if ( page?.testUserPermission(game.user, "LIMITED", {exact: true}) ) {
      // Special-case handling for image pages.
      return page.type === "image";
    }
    const accessTest = page ?? entry;
    return accessTest.testUserPermission(game.user, "OBSERVER");
  }

  /* -------------------------------------------- */

  /** @override */
  _canConfigure(user) {
    return canvas.notes.active && this.document.canUserModify(game.user, "update");
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onClickLeft2(event) {
    const {entry, page} = this.document;
    if ( !entry ) return;
    const options = {};
    if ( page ) {
      options.mode = JournalSheet.VIEW_MODES.SINGLE;
      options.pageId = page.id;
    }
    const allowed = Hooks.call("activateNote", this, options);
    if ( allowed === false ) return;
    if ( page?.type === "image" ) {
      return new ImagePopout(page.src, {
        uuid: page.uuid,
        title: page.name,
        caption: page.image.caption
      }).render(true);
    }
    entry.sheet.render(true, options);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get text() {
    const msg = "Note#text has been deprecated. Use Note#document#label instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.document.label;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get size() {
    const msg = "Note#size has been deprecated. Use Note#document#iconSize instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.document.iconSize;
  }
}

/**
 * A Region is an implementation of PlaceableObject which represents a Region document
 * within a viewed Scene on the game canvas.
 * @category - Canvas
 * @see {RegionDocument}
 * @see {RegionLayer}
 */
class Region extends PlaceableObject {
  constructor(document) {
    super(document);
    this.#initialize();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static embeddedName = "Region";

  /* -------------------------------------------- */

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshBorder"], alias: true},
    refreshState: {},
    refreshBorder: {}
  };

  /* -------------------------------------------- */

  static {
    /**
     * The scaling factor used for Clipper paths.
     * @type {number}
     */
    Object.defineProperty(this, "CLIPPER_SCALING_FACTOR", {value: 100});

    /**
     * The three movement segment types: ENTER, MOVE, and EXIT.
     * @enum {number}
     */
    Object.defineProperty(this, "MOVEMENT_SEGMENT_TYPES", {value: Object.freeze({
      /**
       * The segment crosses the boundary of the region and exits it.
       */
      EXIT: -1,

      /**
       * The segment does not cross the boundary of the region and is contained within it.
       */
      MOVE: 0,

      /**
       * The segment crosses the boundary of the region and enters it.
       */
      ENTER: 1
    })});
  }

  /* -------------------------------------------- */

  /**
   * A temporary point used by this class.
   * @type {PIXI.Point}
   */
  static #SHARED_POINT = new PIXI.Point();

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The shapes of this Region in draw order.
   * @type {ReadonlyArray<RegionShape>}
   */
  get shapes() {
    return this.#shapes ??= this.document.shapes.map(shape => foundry.canvas.regions.RegionShape.create(shape));
  }

  #shapes;

  /* -------------------------------------------- */

  /**
   * The bottom elevation of this Region.
   * @type {number}
   */
  get bottom() {
    return this.document.elevation.bottom ?? -Infinity;
  }

  /* -------------------------------------------- */

  /**
   * The top elevation of this Region.
   * @type {number}
   */
  get top() {
    return this.document.elevation.top ?? Infinity;
  }

  /* -------------------------------------------- */

  /**
   * The polygons of this Region.
   * @type {ReadonlyArray<PIXI.Polygon>}
   */
  get polygons() {
    return this.#polygons ??= Array.from(this.polygonTree, node => node.polygon);
  }

  #polygons;

  /* -------------------------------------------- */

  /**
   * The polygon tree of this Region.
   * @type {RegionPolygonTree}
   */
  get polygonTree() {
    return this.#polygonTree ??= foundry.canvas.regions.RegionPolygonTree._fromClipperPolyTree(
      this.#createClipperPolyTree());
  }

  #polygonTree;

  /* -------------------------------------------- */

  /**
   * The Clipper paths of this Region.
   * @type {ReadonlyArray<ReadonlyArray<ClipperLib.IntPoint>>}
   */
  get clipperPaths() {
    return this.#clipperPaths ??= Array.from(this.polygonTree, node => node.clipperPath);
  }

  #clipperPaths;

  /* -------------------------------------------- */

  /**
   * The triangulation of this Region.
   * @type {Readonly<{vertices: Float32Array, indices: Uint16Array|Uint32Array}>}
   */
  get triangulation() {
    let triangulation = this.#triangulation;
    if ( !this.#triangulation ) {
      let vertexIndex = 0;
      let vertexDataSize = 0;
      for ( const node of this.polygonTree ) vertexDataSize += node.points.length;
      const vertexData = new Float32Array(vertexDataSize);
      const indices = [];
      for ( const node of this.polygonTree ) {
        if ( node.isHole ) continue;
        const holes = [];
        let points = node.points;
        for ( const hole of node.children ) {
          holes.push(points.length / 2);
          points = points.concat(hole.points);
        }
        const triangles = PIXI.utils.earcut(points, holes, 2);
        const offset = vertexIndex / 2;
        for ( let i = 0; i < triangles.length; i++ ) indices.push(triangles[i] + offset);
        for ( let i = 0; i < points.length; i++ ) vertexData[vertexIndex++] = points[i];
      }
      const indexDataType = vertexDataSize / 2 > 65536 ? Uint32Array : Uint16Array;
      const indexData = new indexDataType(indices);
      this.#triangulation = triangulation = {vertices: vertexData, indices: indexData};
    }
    return triangulation;
  }

  #triangulation;

  /* -------------------------------------------- */

  /**
   * The geometry of this Region.
   * @type {RegionGeometry}
   */
  get geometry() {
    return this.#geometry;
  }

  #geometry = new foundry.canvas.regions.RegionGeometry(this);

  /* -------------------------------------------- */

  /** @override */
  get bounds() {
    let bounds = this.#bounds;
    if ( !bounds ) {
      const nodes = this.polygonTree.children;
      if ( nodes.length === 0 ) bounds = new PIXI.Rectangle();
      else {
        bounds = nodes[0].bounds.clone();
        for ( let i = 1; i < nodes.length; i++ ) {
          bounds.enlarge(nodes[i].bounds);
        }
      }
      this.#bounds = bounds;
    }
    return bounds.clone(); // PlaceableObject#bounds always returns a new instance
  }

  #bounds;

  /* -------------------------------------------- */

  /** @override */
  get center() {
    const {x, y} = this.bounds.center;
    return new PIXI.Point(x, y);
  }

  /* -------------------------------------------- */

  /**
   * Is this Region currently visible on the Canvas?
   * @type {boolean}
   */
  get isVisible() {
    if ( this.sheet?.rendered ) return true;
    if ( !this.layer.legend._isRegionVisible(this) ) return false;
    const V = CONST.REGION_VISIBILITY;
    switch ( this.document.visibility ) {
      case V.LAYER: return this.layer.active;
      case V.GAMEMASTER: return game.user.isGM;
      case V.ALWAYS: return true;
      default: throw new Error("Invalid visibility");
    }
  }

  /* -------------------------------------------- */

  /**
   * The highlight of this Region.
   * @type {RegionMesh}
   */
  #highlight;

  /* -------------------------------------------- */

  /**
   * The border of this Region.
   * @type {PIXI.Graphics}
   */
  #border;

  /* -------------------------------------------- */

  /** @override */
  getSnappedPosition(position) {
    throw new Error("Region#getSnappedPosition is not supported: RegionDocument does not have a (x, y) position");
  }

  /* -------------------------------------------- */

  /* -------------------------------------------- */
  /*  Initialization                              */
  /* -------------------------------------------- */

  /**
   * Initialize the Region.
   */
  #initialize() {
    this.#updateShapes();
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.#highlight = this.addChild(new foundry.canvas.regions.RegionMesh(this, HighlightRegionShader));
    this.#highlight.eventMode = "auto";
    this.#highlight.shader.uniforms.hatchThickness = canvas.dimensions.size / 25;
    this.#highlight.alpha = 0.5;
    this.#border = this.addChild(new PIXI.Graphics());
    this.#border.eventMode = "none";
    this.cursor = "pointer";
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @override */
  _applyRenderFlags(flags) {
    if ( flags.refreshState ) this._refreshState();
    if ( flags.refreshBorder ) this._refreshBorder();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the state of the Region.
   * @protected
   */
  _refreshState() {
    const wasVisible = this.visible;
    this.visible = this.isVisible;
    if ( this.visible !== wasVisible ) MouseInteractionManager.emulateMoveEvent();
    this.zIndex = this.controlled ? 2 : this.hover ? 1 : 0;
    const oldEventMode = this.eventMode;
    this.eventMode = this.layer.active && (game.activeTool === "select") ? "static" : "none";
    if ( this.eventMode !== oldEventMode ) MouseInteractionManager.emulateMoveEvent();
    const {locked, color} = this.document;
    this.#highlight.tint = color;
    this.#highlight.shader.uniforms.hatchEnabled = !this.controlled && !this.hover;
    const colors = CONFIG.Canvas.dispositionColors;
    this.#border.tint = this.controlled ? (locked ? colors.HOSTILE : colors.CONTROLLED) : colors.INACTIVE;
    this.#border.visible = this.controlled || this.hover || this.layer.highlightObjects;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the border of the Region.
   * @protected
   */
  _refreshBorder() {
    const thickness = CONFIG.Canvas.objectBorderThickness;
    this.#border.clear();
    for ( const lineStyle of [
      {width: thickness, color: 0x000000, join: PIXI.LINE_JOIN.ROUND, alignment: 0.75},
      {width: thickness / 2, color: 0xFFFFFF, join: PIXI.LINE_JOIN.ROUND, alignment: 1}
    ]) {
      this.#border.lineStyle(lineStyle);
      for ( const node of this.polygonTree ) {
        if ( node.isHole ) continue;
        this.#border.drawShape(node.polygon);
        this.#border.beginHole();
        for ( const hole of node.children ) this.#border.drawShape(hole.polygon);
        this.#border.endHole();
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _canDrag(user, event) {
    return false; // Regions cannot be dragged
  }

  /* -------------------------------------------- */

  /** @override */
  _canHUD(user, event) {
    return false; // Regions don't have a HUD
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onControl(options) {
    super._onControl(options);
    this.layer.legend.render();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRelease(options) {
    super._onRelease(options);
    if ( this.layer.active ) {
      ui.controls.initialize({tool: "select"});
      this.layer.legend.render();
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onHoverIn(event, {updateLegend=true, ...options}={}) {
    if ( updateLegend ) this.layer.legend._hoverRegion(this, true);
    return super._onHoverIn(event, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onHoverOut(event, {updateLegend=true, ...options}={}) {
    if ( updateLegend ) this.layer.legend._hoverRegion(this, false);
    return super._onHoverOut(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _overlapsSelection(rectangle) {
    if ( !rectangle.intersects(this.bounds) ) return false;
    const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
    const x0 = Math.round(rectangle.left * scalingFactor);
    const y0 = Math.round(rectangle.top * scalingFactor);
    const x1 = Math.round(rectangle.right * scalingFactor);
    const y1 = Math.round(rectangle.bottom * scalingFactor);
    if ( (x0 === x1) || (y0 === y1) ) return false;
    const rectanglePath = [
      new ClipperLib.IntPoint(x0, y0),
      new ClipperLib.IntPoint(x1, y0),
      new ClipperLib.IntPoint(x1, y1),
      new ClipperLib.IntPoint(x0, y1)
    ];
    const clipper = new ClipperLib.Clipper();
    const solution = [];
    clipper.Clear();
    clipper.AddPath(rectanglePath, ClipperLib.PolyType.ptSubject, true);
    clipper.AddPaths(this.clipperPaths, ClipperLib.PolyType.ptClip, true);
    clipper.Execute(ClipperLib.ClipType.ctIntersection, solution);
    return solution.length !== 0;
  }

  /* -------------------------------------------- */
  /*  Shape Methods                               */
  /* -------------------------------------------- */

  /**
   * Test whether the given point (at the given elevation) is inside this Region.
   * @param {Point} point           The point.
   * @param {number} [elevation]    The elevation of the point.
   * @returns {boolean}             Is the point (at the given elevation) inside this Region?
   */
  testPoint(point, elevation) {
    return ((elevation === undefined) || ((this.bottom <= elevation) && (elevation <= this.top)))
      && this.polygonTree.testPoint(point);
  }

  /* -------------------------------------------- */

  /**
   * Update the shapes of this region.
   */
  #updateShapes() {
    this.#shapes = undefined;
    this.#polygons = undefined;
    this.#polygonTree = undefined;
    this.#clipperPaths = undefined;
    this.#bounds = undefined;
    this.#triangulation = undefined;
    this.#geometry?._clearBuffers();
  }

  /* -------------------------------------------- */

  /**
   * Create the Clipper polygon tree for this Region.
   * @returns {ClipperLib.PolyTree}
   */
  #createClipperPolyTree() {
    const i0 = this.shapes.findIndex(s => !s.isHole);
    if ( i0 < 0 ) return new ClipperLib.PolyTree();
    if ( i0 === this.shapes.length - 1 ) {
      const shape = this.shapes[i0];
      if ( shape.isHole ) return new ClipperLib.PolyTree();
      return shape.clipperPolyTree;
    }
    const clipper = new ClipperLib.Clipper();
    const batches = this.#buildClipperBatches();
    if ( batches.length === 0 ) return new ClipperLib.PolyTree();
    if ( batches.length === 1 ) {
      const batch = batches[0];
      const tree = new ClipperLib.PolyTree();
      clipper.AddPaths(batch.paths, ClipperLib.PolyType.ptClip, true);
      clipper.Execute(batch.clipType, tree, ClipperLib.PolyFillType.pftNonZero, batch.fillType);
      return tree;
    }
    let subjectPaths = batches[0].paths;
    let subjectFillType = batches[0].fillType;
    for ( let i = 1; i < batches.length; i++ ) {
      const batch = batches[i];
      const solution = i === batches.length - 1 ? new ClipperLib.PolyTree() : [];
      clipper.Clear();
      clipper.AddPaths(subjectPaths, ClipperLib.PolyType.ptSubject, true);
      clipper.AddPaths(batch.paths, ClipperLib.PolyType.ptClip, true);
      clipper.Execute(batch.clipType, solution, subjectFillType, batch.fillType);
      subjectPaths = solution;
      subjectFillType = ClipperLib.PolyFillType.pftNonZero;
    }
    return subjectPaths;
  }

  /* -------------------------------------------- */

  /**
   * Build the Clipper batches.
   * @returns {{paths: ClipperLib.IntPoint[][], fillType: ClipperLib.PolyFillType, clipType: ClipperLib.ClipType}[]}
   */
  #buildClipperBatches() {
    const batches = [];
    const shapes = this.shapes;
    let i = 0;

    // Skip over holes at the beginning
    while ( i < shapes.length ) {
      if ( !shapes[i].isHole ) break;
      i++;
    }

    // Iterate the shapes and batch paths of consecutive (non-)hole shapes
    while ( i < shapes.length ) {
      const paths = [];
      const isHole = shapes[i].isHole;

      // Add paths of the current shape and following shapes until the next shape is (not) a hole
      do {
        for ( const path of shapes[i].clipperPaths ) paths.push(path);
        i++;
      } while ( (i < shapes.length) && (shapes[i].isHole === isHole) );

      // Create a batch from the paths, which are either all holes or all non-holes
      batches.push({
        paths,
        fillType: ClipperLib.PolyFillType.pftNonZero,
        clipType: isHole ? ClipperLib.ClipType.ctDifference : ClipperLib.ClipType.ctUnion
      });
    }
    return batches;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} RegionMovementWaypoint
   * @property {number} x            The x-coordinates in pixels (integer).
   * @property {number} y            The y-coordinates in pixels (integer).
   * @property {number} elevation    The elevation in grid units.
   */

  /**
   * @typedef {object} RegionMovementSegment
   * @property {number} type                    The type of this segment (see {@link Region.MOVEMENT_SEGMENT_TYPES}).
   * @property {RegionMovementWaypoint} from    The waypoint that this segment starts from
   * @property {RegionMovementWaypoint} to      The waypoint that this segment goes to.
   */

  /**
   * Split the movement into its segments.
   * @param {RegionMovementWaypoint[]} waypoints    The waypoints of movement.
   * @param {Point[]} samples                       The points relative to the waypoints that are tested.
   *                                                Whenever one of them is inside the region, the moved object
   *                                                is considered to be inside the region.
   * @param {object} [options]                      Additional options
   * @param {boolean} [options.teleport=false]      Is it teleportation?
   * @returns {RegionMovementSegment[]}             The movement split into its segments.
   */
  segmentizeMovement(waypoints, samples, {teleport=false}={}) {
    if ( samples.length === 0 ) return [];
    let segments = [];
    for ( let i = 1; i < waypoints.length; i++ ) {
      for ( const segment of this.#segmentizeMovement(waypoints[i - 1], waypoints[i], samples, teleport) ) {
        segments.push(segment);
      }
    }
    return segments;
  }

  /* -------------------------------------------- */

  /**
   * Split the movement into its segments.
   * @param {RegionMovementWaypoint} origin         The origin of movement.
   * @param {RegionMovementWaypoint} destination    The destination of movement.
   * @param {Point[]} samples                       The points relative to the waypoints that are tested.
   * @param {boolean} teleport                      Is it teleportation?
   * @returns {RegionMovementSegment[]}             The movement split into its segments.
   */
  #segmentizeMovement(origin, destination, samples, teleport) {
    const originX = Math.round(origin.x);
    const originY = Math.round(origin.y);
    const originElevation = origin.elevation;
    const destinationX = Math.round(destination.x);
    const destinationY = Math.round(destination.y);
    const destinationElevation = destination.elevation;

    // If same origin and destination, there are no segments
    if ( (originX === destinationX) && (originY === destinationY)
      && (originElevation === destinationElevation) ) return [];

    // If teleport, move directly
    if ( teleport ) {
      const segment = this.#getTeleportationSegment(originX, originY, originElevation,
        destinationX, destinationY, destinationElevation, samples);
      return segment ? [segment] : [];
    }

    // If no elevation change, we don't have to deal with enter/exit segments at the bottom/top elevation range
    if ( originElevation === destinationElevation ) {
      if ( !((this.bottom <= originElevation) && (originElevation <= this.top)) ) return [];
      return this.#getMovementSegments(originX, originY, originElevation,
        destinationX, destinationY, destinationElevation, samples);
    }

    // Calculate the first and last elevation within the elevation range of this Region
    const upwards = originElevation < destinationElevation;
    const e1 = upwards ? Math.max(originElevation, this.bottom) : Math.min(originElevation, this.top);
    const e2 = upwards ? Math.min(destinationElevation, this.top) : Math.max(destinationElevation, this.bottom);
    const t1 = (e1 - originElevation) / (destinationElevation - originElevation);
    const t2 = (e2 - originElevation) / (destinationElevation - originElevation);

    // Return if there's no intersection
    if ( t1 > t2 ) return [];

    // Calculate the first and last position of movement in the elevation range of this Region
    const x1 = Math.round(Math.mix(originX, destinationX, t1));
    const y1 = Math.round(Math.mix(originY, destinationY, t1));
    const x2 = Math.round(Math.mix(originX, destinationX, t2));
    const y2 = Math.round(Math.mix(originY, destinationY, t2));

    // Get movements segments within the elevation range of this Region
    const segments = this.#getMovementSegments(x1, y1, e1, x2, y2, e2, samples);

    // Add segment if we enter vertically
    if ( (originElevation !== e1) && this.#testSamples(x1, y1, samples) ) {
      const grid = this.document.parent.grid;
      const epsilon = Math.min(Math.abs(originElevation - e1), grid.distance / grid.size);
      segments.unshift({
        type: Region.MOVEMENT_SEGMENT_TYPES.ENTER,
        from: {x: x1, y: y1, elevation: e1 - (upwards ? epsilon : -epsilon)},
        to: {x: x1, y: y1, elevation: e1}
      });
    }

    // Add segment if we exit vertically
    if ( (destinationElevation !== e2) && this.#testSamples(x2, y2, samples) ) {
      const grid = this.document.parent.grid;
      const epsilon = Math.min(Math.abs(destinationElevation - e2), grid.distance / grid.size);
      segments.push({
        type: Region.MOVEMENT_SEGMENT_TYPES.EXIT,
        from: {x: x2, y: y2, elevation: e2},
        to: {x: x2, y: y2, elevation: e2 + (upwards ? epsilon : -epsilon)}
      });
    }
    return segments;
  }

  /* -------------------------------------------- */

  /**
   * Get the teleporation segment from the origin to the destination.
   * @param {number} originX                  The x-coordinate of the origin.
   * @param {number} originY                  The y-coordinate of the origin.
   * @param {number} originElevation          The elevation of the destination.
   * @param {number} destinationX             The x-coordinate of the destination.
   * @param {number} destinationY             The y-coordinate of the destination.
   * @param {number} destinationElevation     The elevation of the destination.
   * @param {Point[]} samples                 The samples relative to the position.
   * @returns {RegionMovementSegment|void}    The teleportation segment, if any.
   */
  #getTeleportationSegment(originX, originY, originElevation, destinationX, destinationY, destinationElevation,
    samples) {
    const positionChanged = (originX !== destinationX) || (originY !== destinationY);
    const elevationChanged = originElevation !== destinationElevation;
    if ( !(positionChanged || elevationChanged) ) return;
    const {bottom, top} = this;
    let originInside = (bottom <= originElevation) && (originElevation <= top);
    let destinationInside = (bottom <= destinationElevation) && (destinationElevation <= top);
    if ( positionChanged ) {
      originInside &&= this.#testSamples(originX, originY, samples);
      destinationInside &&= this.#testSamples(destinationX, destinationY, samples);
    } else if ( originInside || destinationInside ) {
      const inside = this.#testSamples(originX, originY, samples);
      originInside &&= inside;
      destinationInside &&= inside;
    }
    let type;
    if ( originInside && destinationInside) type = Region.MOVEMENT_SEGMENT_TYPES.MOVE;
    else if ( originInside ) type = Region.MOVEMENT_SEGMENT_TYPES.EXIT;
    else if ( destinationInside ) type = Region.MOVEMENT_SEGMENT_TYPES.ENTER;
    else return;
    return {
      type,
      from: {x: originX, y: originY, elevation: originElevation},
      to: {x: destinationX, y: destinationY, elevation: destinationElevation}
    };
  }

  /* -------------------------------------------- */

  /**
   * Test whether one of the samples relative to the given position is contained within this Region.
   * @param {number} x           The x-coordinate of the position.
   * @param {number} y           The y-coordinate of the position.
   * @param {Point[]} samples    The samples relative to the position.
   * @returns {boolean}          Is one of the samples contained within this Region?
   */
  #testSamples(x, y, samples) {
    const point = Region.#SHARED_POINT;
    const n = samples.length;
    for ( let i = 0; i < n; i++ ) {
      const sample = samples[i];
      if ( this.#polygonTree.testPoint(point.set(x + sample.x, y + sample.y)) ) return true;
    }
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Split the movement into its segments.
   * @param {number} originX                 The x-coordinate of the origin.
   * @param {number} originY                 The y-coordinate of the origin.
   * @param {number} originElevation         The elevation of the destination.
   * @param {number} destinationX            The x-coordinate of the destination.
   * @param {number} destinationY            The y-coordinate of the destination.
   * @param {number} destinationElevation    The elevation of the destination.
   * @param {Point[]} samples                The samples relative to the position.
   * @returns {{start: number, end: number}[]}    The intervals where we have an intersection.
   */
  #getMovementSegments(originX, originY, originElevation, destinationX, destinationY, destinationElevation, samples) {
    const segments = [];
    if ( (originX === destinationX) && (originY === destinationY) ) {

      // Add move segment if inside and the elevation changed
      if ( (originElevation !== destinationElevation) && this.#testSamples(originX, originY, samples) ) {
        segments.push({
          type: Region.MOVEMENT_SEGMENT_TYPES.MOVE,
          from: {x: originX, y: originY, elevation: originElevation},
          to: {x: destinationX, y: destinationY, elevation: destinationElevation}
        });
      }
      return segments;
    }

    // Test first if the bounds of the movement overlap the bounds of this Region
    if ( !this.#couldMovementIntersect(originX, originY, destinationX, destinationY, samples) ) return segments;

    // Compute the intervals
    const intervals = this.#computeSegmentIntervals(originX, originY, destinationX, destinationY, samples);

    // Compute the segments from the intervals
    for ( const {start, end} of intervals ) {

      // Find crossings (enter and exit) for the interval
      const startX = Math.round(Math.mix(originX, destinationX, start));
      const startY = Math.round(Math.mix(originY, destinationY, start));
      const startElevation = Math.mix(originElevation, destinationElevation, start);
      const endX = Math.round(Math.mix(originX, destinationX, end));
      const endY = Math.round(Math.mix(originY, destinationY, end));
      const endElevation = Math.mix(originElevation, destinationElevation, end);
      const [{x: x00, y: y00, inside: inside00}, {x: x01, y: y01, inside: inside01}] = this.#findBoundaryCrossing(
        originX, originY, startX, startY, endX, endY, samples, true);
      const [{x: x10, y: y10, inside: inside10}, {x: x11, y: y11, inside: inside11}] = this.#findBoundaryCrossing(
        startX, startY, endX, endY, destinationX, destinationY, samples, false);

      // Add enter segment if found
      if ( inside00 !== inside01 ) {
        segments.push({
          type: Region.MOVEMENT_SEGMENT_TYPES.ENTER,
          from: {x: x00, y: y00, elevation: startElevation},
          to: {x: x01, y: y01, elevation: startElevation}
        });
      }

      // Add move segment or enter/exit segment if not completely inside
      if ( (inside01 || inside10) && ((x01 !== x10) || (y01 !== y10)) ) {
        segments.push({
          type: inside01 && inside10 ? Region.MOVEMENT_SEGMENT_TYPES.MOVE
            : inside10 ? Region.MOVEMENT_SEGMENT_TYPES.ENTER : Region.MOVEMENT_SEGMENT_TYPES.EXIT,
          from: {x: x01, y: y01, elevation: startElevation},
          to: {x: x10, y: y10, elevation: endElevation}
        });
      }

      // Add exit segment if found
      if ( inside10 !== inside11 ) {
        segments.push({
          type: Region.MOVEMENT_SEGMENT_TYPES.EXIT,
          from: {x: x10, y: y10, elevation: endElevation},
          to: {x: x11, y: y11, elevation: endElevation}
        });
      }
    }

    // Make sure we have segments for origins/destinations inside the region
    const originInside = this.#testSamples(originX, originY, samples);
    const destinationInside = this.#testSamples(destinationX, destinationY, samples);

    // If neither the origin nor the destination are inside, we are done
    if ( !originInside && !destinationInside ) return segments;

    // If we didn't find segments with the method above, we need to add segments for the origin and/or destination
    if ( segments.length === 0 ) {

      // If the origin is inside, look for a crossing (exit) after the origin
      if ( originInside ) {
        const [{x: x0, y: y0}, {x: x1, y: y1, inside: inside1}] = this.#findBoundaryCrossing(
          originX, originY, originX, originY, destinationX, destinationY, samples, false);
        if ( !inside1 ) {

          // If we don't exit at the origin, add a move segment
          if ( (originX !== x0) || (originY !== y0) ) {
            segments.push({
              type: Region.MOVEMENT_SEGMENT_TYPES.MOVE,
              from: {x: originX, y: originY, elevation: originElevation},
              to: {x: x0, y: y0, elevation: originElevation}
            });
          }

          // Add the exit segment that we found
          segments.push({
            type: Region.MOVEMENT_SEGMENT_TYPES.EXIT,
            from: {x: x0, y: y0, elevation: originElevation},
            to: {x: x1, y: y1, elevation: originElevation}
          });
        }
      }

      // If the destination is inside, look for a crossing (enter) before the destination
      if ( destinationInside ) {
        const [{x: x0, y: y0, inside: inside0}, {x: x1, y: y1}] = this.#findBoundaryCrossing(
          originX, originY, destinationX, destinationY, destinationX, destinationY, samples, true);
        if ( !inside0 ) {

          // Add the enter segment that we found
          segments.push({
            type: Region.MOVEMENT_SEGMENT_TYPES.ENTER,
            from: {x: x0, y: y0, elevation: destinationElevation},
            to: {x: x1, y: y1, elevation: destinationElevation}
          });

          // If we don't enter at the destination, add a move segment
          if ( (destinationX !== x1) || (destinationY !== y1) ) {
            segments.push({
              type: Region.MOVEMENT_SEGMENT_TYPES.MOVE,
              from: {x: x1, y: y1, elevation: destinationElevation},
              to: {x: destinationX, y: destinationY, elevation: destinationElevation}
            });
          }
        }
      }

      // If both are inside and we didn't find we didn't find a crossing, the entire segment is contained
      if ( originInside && destinationInside && (segments.length === 0) ) {
        segments.push({
          type: Region.MOVEMENT_SEGMENT_TYPES.MOVE,
          from: {x: originX, y: originY, elevation: originElevation},
          to: {x: destinationX, y: destinationY, elevation: destinationElevation}
        });
      }
    }

    // We have segments and know we make sure that the origin and/or destination that are inside are
    // part of those segments. If they are not we either need modify the first/last segment or add
    // segments to the beginning/end.
    else {

      // Make sure we have a segment starting at the origin if it is inside
      if ( originInside ) {
        const first = segments.at(0);
        const {x: firstX, y: firstY} = first.from;
        if ( (originX !== firstX) || (originY !== firstY) ) {

          // The first segment is an enter segment, so we need to add an exit segment before this one
          if ( first.type === 1 ) {
            const [{x: x0, y: y0}, {x: x1, y: y1}] = this.#findBoundaryCrossing(
              firstX, firstY, originX, originY, originX, originY, samples, false);
            segments.unshift({
              type: Region.MOVEMENT_SEGMENT_TYPES.EXIT,
              from: {x: x0, y: y0, elevation: originElevation},
              to: {x: x1, y: y1, elevation: originElevation}
            });
          }

          // We have an exit or move segment, in which case we can simply update the from position
          else {
            first.from.x = originX;
            first.from.y = originY;
          }
        }
      }

      // Make sure we have a segment ending at the destination if it is inside
      if ( destinationInside ) {
        const last = segments.at(-1);
        const {x: lastX, y: lastY} = last.to;
        if ( (destinationX !== lastX) || (destinationY !== lastY) ) {

          // The last segment is an exit segment, so we need to add an enter segment after this one
          if ( last.type === -1 ) {
            const [{x: x0, y: y0}, {x: x1, y: y1}] = this.#findBoundaryCrossing(
              lastX, lastY, destinationX, destinationY, destinationX, destinationY, samples, true);
            segments.push({
              type: Region.MOVEMENT_SEGMENT_TYPES.ENTER,
              from: {x: x0, y: y0, elevation: destinationElevation},
              to: {x: x1, y: y1, elevation: destinationElevation}
            });
          }

          // We have an enter or move segment, in which case we can simply update the to position
          else {
            last.to.x = destinationX;
            last.to.y = destinationY;
          }
        }
      }
    }
    return segments;
  }

  /* -------------------------------------------- */

  /**
   * Test whether the movement could intersect this Region.
   * @param {number} originX         The x-coordinate of the origin.
   * @param {number} originY         The y-coordinate of the origin.
   * @param {number} destinationX    The x-coordinate of the destination.
   * @param {number} destinationY    The y-coordinate of the destination.
   * @param {Point[]} samples        The samples relative to the position.
   * @returns {boolean}              Could the movement intersect?
   */
  #couldMovementIntersect(originX, originY, destinationX, destinationY, samples) {
    let {x: minX, y: minY} = samples[0];
    let maxX = minX;
    let maxY = minY;
    for ( let i = 1; i < samples.length; i++ ) {
      const {x, y} = samples[i];
      minX = Math.min(minX, x);
      minY = Math.min(minY, y);
      maxX = Math.max(maxX, x);
      maxY = Math.max(maxY, y);
    }
    minX += Math.min(originX, destinationX);
    minY += Math.min(originY, destinationY);
    maxX += Math.max(originX, destinationX);
    maxY += Math.max(originY, destinationY);
    const {left, right, top, bottom} = this.bounds;
    return (Math.max(minX, left - 1) <= Math.min(maxX, right + 1))
      && (Math.max(minY, top - 1) <= Math.min(maxY, bottom + 1));
  }

  /* -------------------------------------------- */

  /**
   * Compute the intervals of intersection of the movement.
   * @param {number} originX         The x-coordinate of the origin.
   * @param {number} originY         The y-coordinate of the origin.
   * @param {number} destinationX    The x-coordinate of the destination.
   * @param {number} destinationY    The y-coordinate of the destination.
   * @param {Point[]} samples        The samples relative to the position.
   * @returns {{start: number, end: number}[]}    The intervals where we have an intersection.
   */
  #computeSegmentIntervals(originX, originY, destinationX, destinationY, samples) {
    const scalingFactor = Region.CLIPPER_SCALING_FACTOR;
    const intervals = [];
    const clipper = new ClipperLib.Clipper();
    const solution = new ClipperLib.PolyTree();
    const origin = new ClipperLib.IntPoint(0, 0);
    const destination = new ClipperLib.IntPoint(0, 0);
    const lineSegment = [origin, destination];

    // Calculate the intervals for each of the line segments
    for ( const {x: dx, y: dy} of samples ) {
      origin.X = Math.round((originX + dx) * scalingFactor);
      origin.Y = Math.round((originY + dy) * scalingFactor);
      destination.X = Math.round((destinationX + dx) * scalingFactor);
      destination.Y = Math.round((destinationY + dy) * scalingFactor);

      // Intersect the line segment with the geometry of this Region
      clipper.Clear();
      clipper.AddPath(lineSegment, ClipperLib.PolyType.ptSubject, false);
      clipper.AddPaths(this.clipperPaths, ClipperLib.PolyType.ptClip, true);
      clipper.Execute(ClipperLib.ClipType.ctIntersection, solution);

      // Calculate the intervals of the intersections
      const length = Math.hypot(destination.X - origin.X, destination.Y - origin.Y);
      for ( const [a, b] of ClipperLib.Clipper.PolyTreeToPaths(solution) ) {
        let start = Math.hypot(a.X - origin.X, a.Y - origin.Y) / length;
        let end = Math.hypot(b.X - origin.X, b.Y - origin.Y) / length;
        if ( start > end ) [start, end] = [end, start];
        intervals.push({start, end});
      }
    }

    // Sort and merge intervals
    intervals.sort((i0, i1) => i0.start - i1.start);
    const mergedIntervals = [];
    if ( intervals.length !== 0 ) {
      let i0 = intervals[0];
      mergedIntervals.push(i0);
      for ( let i = 1; i < intervals.length; i++ ) {
        const i1 = intervals[i];
        if ( i0.end < i1.start ) mergedIntervals.push(i0 = i1);
        else i0.end = Math.max(i0.end, i1.end);
      }
    }
    return mergedIntervals;
  }

  /* -------------------------------------------- */

  /**
   * Find the crossing (enter or exit) at the current position between the start and end position, if possible.
   * The current position should be very close to crossing, otherwise we test a lot of pixels potentially.
   * We use Bresenham's line algorithm to walk forward/backwards to find the crossing.
   * @see {@link https://en.wikipedia.org/wiki/Bresenham's_line_algorithm}
   * @param {number} startX      The start x-coordinate.
   * @param {number} startY      The start y-coordinate.
   * @param {number} currentX    The current x-coordinate.
   * @param {number} currentY    The current y-coordinate.
   * @param {number} endX        The end x-coordinate.
   * @param {number} endY        The end y-coordinate.
   * @param {boolean} samples    The samples.
   * @param {boolean} enter      Find enter? Otherwise find exit.
   * @returns {[from: {x: number, y: number, inside: boolean}, to: {x: number, y: number, inside: boolean}]}
   */
  #findBoundaryCrossing(startX, startY, currentX, currentY, endX, endY, samples, enter) {
    let x0 = currentX;
    let y0 = currentY;
    let x1 = x0;
    let y1 = y0;
    let x2;
    let y2;

    // Adjust starting conditions depending on whether we are already inside the Region
    const inside = this.#testSamples(currentX, currentY, samples);
    if ( inside === enter ) {
      x2 = startX;
      y2 = startY;
    } else {
      x2 = endX;
      y2 = endY;
    }
    const sx = x1 < x2 ? 1 : -1;
    const sy = y1 < y2 ? 1 : -1;
    const dx = Math.abs(x1 - x2);
    const dy = 0 - Math.abs(y1 - y2);
    let e = dx + dy;

    // Iterate until we find a crossing point or we reach the start/end position
    while ( (x1 !== x2) || (y1 !== y2) ) {
      const e2 = e * 2;
      if ( e2 <= dx ) {
        e += dx;
        y1 += sy;
      }
      if ( e2 >= dy ) {
        e += dy;
        x1 += sx;
      }

      // If we found the crossing, return it
      if ( this.#testSamples(x1, y1, samples) !== inside ) {
        return inside === enter
          ? [{x: x1, y: y1, inside: !inside}, {x: x0, y: y0, inside}]
          : [{x: x0, y: y0, inside}, {x: x1, y: y1, inside: !inside}];
      }

      x0 = x1;
      y0 = y1;
    }
    return [{x: x1, y: y1, inside}, {x: x1, y: y1, inside}];
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Update the shapes
    if ( "shapes" in changed ) this.#updateShapes();

    // Incremental Refresh
    this.renderFlags.set({
      refreshState: ("color" in changed) || ("visibility" in changed) || ("locked" in changed),
      refreshBorder: "shapes" in changed
    });
  }
}

/**
 * An AmbientSound is an implementation of PlaceableObject which represents a dynamic audio source within the Scene.
 * @category - Canvas
 * @see {@link AmbientSoundDocument}
 * @see {@link SoundsLayer}
 */
class AmbientSound extends PlaceableObject {

  /**
   * The Sound which manages playback for this AmbientSound effect
   * @type {foundry.audio.Sound|null}
   */
  sound;

  /**
   * A sound effect attached to the managed Sound instance.
   * @type {foundry.audio.BaseSoundEffect}
   */
  #baseEffect;

  /**
   * A  sound effect attached to the managed Sound instance when the sound source is muffled.
   * @type {foundry.audio.BaseSoundEffect}
   */
  #muffledEffect;

  /**
   * Track whether audio effects have been initialized.
   * @type {boolean}
   */
  #effectsInitialized = false;

  /**
   * Is this AmbientSound currently muffled?
   * @type {boolean}
   */
  #muffled = false;

  /**
   * A SoundSource object which manages the area of effect for this ambient sound
   * @type {foundry.canvas.sources.PointSoundSource}
   */
  source;

  /**
   * The area that is affected by this ambient sound.
   * @type {PIXI.Graphics}
   */
  field;

  /** @inheritdoc */
  static embeddedName = "AmbientSound";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshField", "refreshElevation"], alias: true},
    refreshField: {propagate: ["refreshPosition"]},
    refreshPosition: {},
    refreshState: {},
    refreshElevation: {}
  };

  /* -------------------------------------------- */

  /**
   * Create a Sound used to play this AmbientSound object
   * @returns {foundry.audio.Sound|null}
   * @protected
   */
  _createSound() {
    const path = this.document.path;
    if ( !this.id || !path ) return null;
    return game.audio.create({src: path, context: game.audio.environment, singleton: true});
  }

  /* -------------------------------------------- */

  /**
   * Create special effect nodes for the Sound.
   * This only happens once the first time the AmbientSound is synced and again if the effect data changes.
   */
  #createEffects() {
    const sfx = CONFIG.soundEffects;
    const {base, muffled} = this.document.effects;
    this.#baseEffect = this.#muffledEffect = undefined;

    // Base effect
    if ( base.type in sfx ) {
      const cfg = sfx[base.type];
      this.#baseEffect = new cfg.effectClass(this.sound.context, base);
    }

    // Muffled effect
    if ( muffled.type in sfx ) {
      const cfg = sfx[muffled.type];
      this.#muffledEffect = new cfg.effectClass(this.sound.context, muffled);
    }
    this.#effectsInitialized = true;
  }

  /* -------------------------------------------- */

  /**
   * Update the set of effects which are applied to the managed Sound.
   * @param {object} [options]
   * @param {boolean} [options.muffled]     Is the sound currently muffled?
   */
  applyEffects({muffled=false}={}) {
    const effects = [];
    if ( muffled ) {
      const effect = this.#muffledEffect || this.#baseEffect;
      if ( effect ) effects.push(effect);
    }
    else if ( this.#baseEffect ) effects.push(this.#baseEffect);
    this.sound.applyEffects(effects);
  }

  /* -------------------------------------------- */
  /* Properties
  /* -------------------------------------------- */

  /**
   * Is this ambient sound is currently audible based on its hidden state and the darkness level of the Scene?
   * @type {boolean}
   */
  get isAudible() {
    if ( this.document.hidden || !this.document.radius ) return false;
    return canvas.darknessLevel.between(this.document.darkness.min ?? 0, this.document.darkness.max ?? 1);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get bounds() {
    const {x, y} = this.document;
    const r = this.radius;
    return new PIXI.Rectangle(x-r, y-r, 2*r, 2*r);
  }

  /* -------------------------------------------- */

  /**
   * A convenience accessor for the sound radius in pixels
   * @type {number}
   */
  get radius() {
    let d = canvas.dimensions;
    return ((this.document.radius / d.distance) * d.size);
  }

  /* -------------------------------------------- */
  /* Methods
  /* -------------------------------------------- */

  /**
   * Toggle playback of the sound depending on whether it is audible.
   * @param {boolean} isAudible     Is the sound audible?
   * @param {number} [volume]       The target playback volume
   * @param {object} [options={}]   Additional options which affect sound synchronization
   * @param {number} [options.fade=250]       A duration in milliseconds to fade volume transition
   * @param {boolean} [options.muffled=false] Is the sound current muffled?
   * @returns {Promise<void>}       A promise which resolves once sound playback is synchronized
   */
  async sync(isAudible, volume, {fade=250, muffled=false}={}) {

    // Discontinue playback
    if ( !isAudible ) {
      if ( !this.sound ) return;
      this.sound._manager = null;
      await this.sound.stop({volume: 0, fade});
      this.#muffled = false;
      return;
    }

    // Begin playback
    this.sound ||= this._createSound();
    if ( this.sound === null ) return;
    const sound = this.sound;

    // Track whether the AmbientSound placeable managing Sound playback has changed
    const objectChange = sound._manager !== this;
    const requireLoad = !sound.loaded && !sound._manager;
    sound._manager = this;

    // Load the buffer if necessary
    if ( requireLoad ) await sound.load();
    if ( !sound.loaded ) return;  // Some other Placeable may be loading the sound

    // Update effects
    const muffledChange = this.#muffled !== muffled;
    this.#muffled = muffled;
    if ( objectChange && !this.#effectsInitialized ) this.#createEffects();
    if ( objectChange || muffledChange ) this.applyEffects({muffled});

    // Begin playback at the desired volume
    if ( !sound.playing ) {
      const offset = sound.context.currentTime % sound.duration;
      await sound.play({volume, offset, fade, loop: true});
      return;
    }

    // Adjust volume
    await sound.fade(volume, {duration: fade});
  }

  /* -------------------------------------------- */
  /* Rendering
  /* -------------------------------------------- */

  /** @inheritdoc */
  clear() {
    if ( this.controlIcon ) {
      this.controlIcon.parent.removeChild(this.controlIcon).destroy();
      this.controlIcon = null;
    }
    return super.clear();
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.field = this.addChild(new PIXI.Graphics());
    this.field.eventMode = "none";
    this.controlIcon = this.addChild(this.#drawControlIcon());
  }

  /* -------------------------------------------- */

  /** @override */
  _destroy(options) {
    this.#destroySoundSource();
  }

  /* -------------------------------------------- */

  /**
   * Draw the ControlIcon for the AmbientLight
   * @returns {ControlIcon}
   */
  #drawControlIcon() {
    const size = Math.max(Math.round((canvas.dimensions.size * 0.5) / 20) * 20, 40);
    let icon = new ControlIcon({texture: CONFIG.controlIcons.sound, size: size});
    icon.x -= (size * 0.5);
    icon.y -= (size * 0.5);
    return icon;
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @override */
  _applyRenderFlags(flags) {
    if ( flags.refreshState ) this._refreshState();
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshField ) this._refreshField();
    if ( flags.refreshElevation ) this._refreshElevation();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the shape of the sound field-of-effect. This is refreshed when the SoundSource fov polygon changes.
   * @protected
   */
  _refreshField() {
    this.field.clear();
    if ( !this.source?.shape ) return;
    this.field.lineStyle(1, 0xFFFFFF, 0.5).beginFill(0xAADDFF, 0.15).drawShape(this.source.shape).endFill();
    this.field.position.set(-this.source.x, -this.source.y);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the position of the AmbientSound. Called with the coordinates change.
   * @protected
   */
  _refreshPosition() {
    const {x, y} = this.document;
    if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
    this.position.set(x, y);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the state of the light. Called when the disabled state or darkness conditions change.
   * @protected
   */
  _refreshState() {
    this.alpha = this._getTargetAlpha();
    this.zIndex = this.hover ? 1 : 0;
    this.refreshControl();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the display of the ControlIcon for this AmbientSound source.
   */
  refreshControl() {
    const isHidden = this.id && (this.document.hidden || !this.document.path);
    this.controlIcon.tintColor = isHidden ? 0xFF3300 : 0xFFFFFF;
    this.controlIcon.borderColor = isHidden ? 0xFF3300 : 0xFF5500;
    this.controlIcon.texture = getTexture(this.isAudible ? CONFIG.controlIcons.sound : CONFIG.controlIcons.soundOff);
    this.controlIcon.elevation = this.document.elevation;
    this.controlIcon.refresh({visible: this.layer.active, borderVisible: this.hover || this.layer.highlightObjects});
    this.controlIcon.draw();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the elevation of the control icon.
   * @protected
   */
  _refreshElevation() {
    this.controlIcon.elevation = this.document.elevation;
  }

  /* -------------------------------------------- */
  /*  Sound Source Management                     */
  /* -------------------------------------------- */

  /**
   * Compute the field-of-vision for an object, determining its effective line-of-sight and field-of-vision polygons
   * @param {object} [options={}]   Options which modify how the audio source is updated
   * @param {boolean} [options.deleted]  Indicate that this SoundSource has been deleted.
   */
  initializeSoundSource({deleted=false}={}) {
    const wasActive = this.layer.sources.has(this.sourceId);
    const perceptionFlags = {refreshSounds: true};

    // Remove the audio source from the Scene
    if ( deleted ) {
      if ( !wasActive ) return;
      this.#destroySoundSource();
      canvas.perception.update(perceptionFlags);
      return;
    }

    // Create the sound source if necessary
    this.source ??= this.#createSoundSource();

    // Re-initialize source data and add to the active collection
    this.source.initialize(this._getSoundSourceData());
    this.source.add();

    // Schedule a perception refresh, unless that operation is deferred for some later workflow
    canvas.perception.update(perceptionFlags);
    if ( this.layer.active ) this.renderFlags.set({refreshField: true});
  }

  /* -------------------------------------------- */

  /**
   * Create a new point sound source for this AmbientSound.
   * @returns {foundry.canvas.sources.PointSoundSource} The created source
   */
  #createSoundSource() {
    const cls = CONFIG.Canvas.soundSourceClass;
    return new cls({sourceId: this.sourceId, object: this});
  }

  /* -------------------------------------------- */

  /**
   * Destroy the point sound source for this AmbientSound.
   */
  #destroySoundSource() {
    this.source?.destroy();
    this.source = undefined;
  }

  /* -------------------------------------------- */

  /**
   * Get the sound source data.
   * @returns {BaseEffectSourceData}
   * @protected
   */
  _getSoundSourceData() {
    return {
      x: this.document.x,
      y: this.document.y,
      elevation: this.document.elevation,
      radius: Math.clamp(this.radius, 0, canvas.dimensions.maxR),
      walls: this.document.walls,
      disabled: !this.isAudible
    };
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    this.initializeSoundSource();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Change the Sound buffer
    if ( "path" in changed ) {
      if ( this.sound ) this.sound.stop();
      this.sound = this._createSound();
    }

    // Update special effects
    if ( "effects" in changed ) {
      this.#effectsInitialized = false;
      if ( this.sound?._manager === this ) this.sound._manager = null;
    }

    // Re-initialize SoundSource
    this.initializeSoundSource();

    // Incremental Refresh
    this.renderFlags.set({
      refreshState: ("hidden" in changed) || ("path" in changed) || ("darkness" in changed),
      refreshElevation: "elevation" in changed
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    this.sound?.stop();
    this.initializeSoundSource({deleted: true});
    super._onDelete(options, userId);
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _canHUD(user, event) {
    return user.isGM; // Allow GMs to single right-click
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _canConfigure(user, event) {
    return false; // Double-right does nothing
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickRight(event) {
    this.document.update({hidden: !this.document.hidden});
    if ( !this._propagateRightClick(event) ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftMove(event) {
    super._onDragLeftMove(event);
    const clones = event.interactionData.clones || [];
    for ( let c of clones ) {
      c.initializeSoundSource();
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragEnd() {
    this.initializeSoundSource({deleted: true});
    this._original?.initializeSoundSource();
    super._onDragEnd();
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  updateSource({defer=false, deleted=false}={}) {
    const msg = "AmbientSound#updateSource has been deprecated in favor of AmbientSound#initializeSoundSource";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    this.initializeSoundSource({defer, deleted});
  }
}

/**
 * A type of Placeable Object which highlights an area of the grid as covered by some area of effect.
 * @category - Canvas
 * @see {@link MeasuredTemplateDocument}
 * @see {@link TemplateLayer}
 */
class MeasuredTemplate extends PlaceableObject {

  /**
   * The geometry shape used for testing point intersection
   * @type {PIXI.Circle | PIXI.Ellipse | PIXI.Polygon | PIXI.Rectangle | PIXI.RoundedRectangle}
   */
  shape;

  /**
   * The tiling texture used for this template, if any
   * @type {PIXI.Texture}
   */
  texture;

  /**
   * The template graphics
   * @type {PIXI.Graphics}
   */
  template;

  /**
   * The measurement ruler label
   * @type {PreciseText}
   */
  ruler;

  /**
   * Internal property used to configure the control border thickness
   * @type {number}
   * @protected
   */
  _borderThickness = 3;

  /** @inheritdoc */
  static embeddedName = "MeasuredTemplate";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshPosition", "refreshShape", "refreshElevation"], alias: true},
    refreshState: {},
    refreshPosition: {propagate: ["refreshGrid"]},
    refreshShape: {propagate: ["refreshTemplate", "refreshGrid", "refreshText"]},
    refreshTemplate: {},
    refreshGrid: {},
    refreshText: {},
    refreshElevation: {}
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A convenient reference for whether the current User is the author of the MeasuredTemplate document.
   * @type {boolean}
   */
  get isAuthor() {
    return this.document.isAuthor;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get bounds() {
    const {x, y} = this.document;
    const d = canvas.dimensions;
    const r = this.document.distance * (d.size / d.distance);
    return new PIXI.Rectangle(x-r, y-r, 2*r, 2*r);
  }

  /* -------------------------------------------- */

  /**
   * Is this MeasuredTemplate currently visible on the Canvas?
   * @type {boolean}
   */
  get isVisible() {
    return !this.document.hidden || this.isAuthor || game.user.isGM;
  }

  /* -------------------------------------------- */

  /**
   * A unique identifier which is used to uniquely identify related objects like a template effect or grid highlight.
   * @type {string}
   */
  get highlightId() {
    return this.objectId;
  }

  /* -------------------------------------------- */
  /*  Initial Drawing                             */
  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {

    // Load Fill Texture
    if ( this.document.texture ) {
      this.texture = await loadTexture(this.document.texture, {fallback: "icons/svg/hazard.svg"});
    } else {
      this.texture = null;
    }

    // Template Shape
    this.template = this.addChild(new PIXI.Graphics());

    // Control Icon
    this.controlIcon = this.addChild(this.#createControlIcon());
    await this.controlIcon.draw();

    // Ruler Text
    this.ruler = this.addChild(this.#drawRulerText());

    // Enable highlighting for this template
    canvas.interface.grid.addHighlightLayer(this.highlightId);
  }

  /* -------------------------------------------- */

  /**
   * Draw the ControlIcon for the MeasuredTemplate
   * @returns {ControlIcon}
   */
  #createControlIcon() {
    const size = Math.max(Math.round((canvas.dimensions.size * 0.5) / 20) * 20, 40);
    let icon = new ControlIcon({texture: CONFIG.controlIcons.template, size: size});
    icon.x -= (size * 0.5);
    icon.y -= (size * 0.5);
    return icon;
  }

  /* -------------------------------------------- */

  /**
   * Draw the Text label used for the MeasuredTemplate
   * @returns {PreciseText}
   */
  #drawRulerText() {
    const style = CONFIG.canvasTextStyle.clone();
    style.fontSize = Math.max(Math.round(canvas.dimensions.size * 0.36 * 12) / 12, 36);
    const text = new PreciseText(null, style);
    text.anchor.set(0, 1);
    return text;
  }

  /* -------------------------------------------- */

  /** @override */
  _destroy(options) {
    canvas.interface.grid.destroyHighlightLayer(this.highlightId);
    this.texture?.destroy();
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @override */
  _applyRenderFlags(flags) {
    if ( flags.refreshState ) this._refreshState();
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshShape ) this._refreshShape();
    if ( flags.refreshTemplate ) this._refreshTemplate();
    if ( flags.refreshGrid ) this.highlightGrid();
    if ( flags.refreshText ) this._refreshRulerText();
    if ( flags.refreshElevation ) this._refreshElevation();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the displayed state of the MeasuredTemplate.
   * This refresh occurs when the user interaction state changes.
   * @protected
   */
  _refreshState() {

    // Template Visibility
    const wasVisible = this.visible;
    this.visible = this.isVisible && !this.hasPreview;
    if ( this.visible !== wasVisible ) MouseInteractionManager.emulateMoveEvent();

    // Sort on top of others on hover
    this.zIndex = this.hover ? 1 : 0;

    // Control Icon Visibility
    const isHidden = this.document.hidden;
    this.controlIcon.refresh({
      visible: this.visible && this.layer.active && this.document.isOwner,
      iconColor: isHidden ? 0xFF3300 : 0xFFFFFF,
      borderColor: isHidden ? 0xFF3300 : 0xFF5500,
      borderVisible: this.hover || this.layer.highlightObjects
    });

    // Alpha transparency
    const alpha = isHidden ? 0.5 : 1;
    this.template.alpha = alpha;
    this.ruler.alpha = alpha;
    const highlightLayer = canvas.interface.grid.getHighlightLayer(this.highlightId);
    highlightLayer.visible = this.visible;
    // FIXME the elevation is not considered in sort order of the highlight layers
    highlightLayer.zIndex = this.document.sort;
    highlightLayer.alpha = alpha;
    this.alpha = this._getTargetAlpha();

    // Ruler Visibility
    this.ruler.visible = this.visible && this.layer.active;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the elevation of the control icon.
   * @protected
   */
  _refreshElevation() {
    this.controlIcon.elevation = this.document.elevation;
  }

  /* -------------------------------------------- */

  /** @override */
  _getTargetAlpha() {
    return this.isPreview ? 0.8 : 1.0;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the position of the MeasuredTemplate
   * @protected
   */
  _refreshPosition() {
    const {x, y} = this.document;
    if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
    this.position.set(x, y);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the underlying geometric shape of the MeasuredTemplate.
   * @protected
   */
  _refreshShape() {
    let {x, y, direction, distance} = this.document;

    // Grid type
    if ( game.settings.get("core", "gridTemplates") ) {
      this.ray = new Ray({x, y}, canvas.grid.getTranslatedPoint({x, y}, direction, distance));
    }

    // Euclidean type
    else {
      this.ray = Ray.fromAngle(x, y, Math.toRadians(direction), distance * canvas.dimensions.distancePixels);
    }

    // Get the Template shape
    this.shape = this._computeShape();
  }

  /* -------------------------------------------- */

  /**
   * Compute the geometry for the template using its document data.
   * Subclasses can override this method to take control over how different shapes are rendered.
   * @returns {PIXI.Circle|PIXI.Rectangle|PIXI.Polygon}
   * @protected
   */
  _computeShape() {
    const {t, distance, direction, angle, width} = this.document;
    switch ( t ) {
      case "circle":
        return this.constructor.getCircleShape(distance);
      case "cone":
        return this.constructor.getConeShape(distance, direction, angle);
      case "rect":
        return this.constructor.getRectShape(distance, direction);
      case "ray":
        return this.constructor.getRayShape(distance, direction, width);
    }
  }

  /* -------------------------------------------- */

  /**
   * Refresh the display of the template outline and shape.
   * Subclasses may override this method to take control over how the template is visually rendered.
   * @protected
   */
  _refreshTemplate() {
    const t = this.template.clear();

    // Draw the Template outline
    t.lineStyle(this._borderThickness, this.document.borderColor, 0.75).beginFill(0x000000, 0.0);

    // Fill Color or Texture
    if ( this.texture ) t.beginTextureFill({texture: this.texture});
    else t.beginFill(0x000000, 0.0);

    // Draw the shape
    t.drawShape(this.shape);

    // Draw origin and destination points
    t.lineStyle(this._borderThickness, 0x000000)
      .beginFill(0x000000, 0.5)
      .drawCircle(0, 0, 6)
      .drawCircle(this.ray.dx, this.ray.dy, 6)
      .endFill();
  }

  /* -------------------------------------------- */

  /**
   * Get a Circular area of effect given a radius of effect
   * @param {number} distance    The radius of the circle in grid units
   * @returns {PIXI.Circle|PIXI.Polygon}
   */
  static getCircleShape(distance) {

    // Grid circle
    if ( game.settings.get("core", "gridTemplates") ) {
      return new PIXI.Polygon(canvas.grid.getCircle({x: 0, y: 0}, distance));
    }

    // Euclidean circle
    return new PIXI.Circle(0, 0, distance * canvas.dimensions.distancePixels);
  }

  /* -------------------------------------------- */

  /**
   * Get a Conical area of effect given a direction, angle, and distance
   * @param {number} distance     The radius of the cone in grid units
   * @param {number} direction    The direction of the cone in degrees
   * @param {number} angle        The angle of the cone in degrees
   * @returns {PIXI.Polygon|PIXI.Circle}
   */
  static getConeShape(distance, direction, angle) {

    // Grid cone
    if ( game.settings.get("core", "gridTemplates") ) {
      return new PIXI.Polygon(canvas.grid.getCone({x: 0, y: 0}, distance, direction, angle));
    }

    // Euclidean cone
    if ( (distance <= 0) || (angle <= 0) ) return new PIXI.Polygon();
    distance *= canvas.dimensions.distancePixels;
    const coneType = game.settings.get("core", "coneTemplateType");

    // For round cones - approximate the shape with a ray every 3 degrees
    let angles;
    if ( coneType === "round" ) {
      if ( angle >= 360 ) return new PIXI.Circle(0, 0, distance);
      const da = Math.min(angle, 3);
      angles = Array.fromRange(Math.floor(angle/da)).map(a => (angle/-2) + (a*da)).concat([angle/2]);
    }

    // For flat cones, direct point-to-point
    else {
      angle = Math.min(angle, 179);
      angles = [(angle/-2), (angle/2)];
      distance /= Math.cos(Math.toRadians(angle/2));
    }

    // Get the cone shape as a polygon
    const rays = angles.map(a => Ray.fromAngle(0, 0, Math.toRadians(direction + a), distance));
    const points = rays.reduce((arr, r) => {
      return arr.concat([r.B.x, r.B.y]);
    }, [0, 0]).concat([0, 0]);
    return new PIXI.Polygon(points);
  }

  /* -------------------------------------------- */

  /**
   * Get a Rectangular area of effect given a width and height
   * @param {number} distance     The length of the diagonal in grid units
   * @param {number} direction    The direction of the diagonal in degrees
   * @returns {PIXI.Rectangle}
   */
  static getRectShape(distance, direction) {
    let endpoint;

    // Grid rectangle
    if ( game.settings.get("core", "gridTemplates") ) {
      endpoint = canvas.grid.getTranslatedPoint({x: 0, y: 0}, direction, distance);
    }

    // Euclidean rectangle
    else endpoint = Ray.fromAngle(0, 0, Math.toRadians(direction), distance * canvas.dimensions.distancePixels).B;

    return new PIXI.Rectangle(0, 0, endpoint.x, endpoint.y).normalize();
  }

  /* -------------------------------------------- */

  /**
   * Get a rotated Rectangular area of effect given a width, height, and direction
   * @param {number} distance      The length of the ray in grid units
   * @param {number} direction     The direction of the ray in degrees
   * @param {number} width         The width of the ray in grid units
   * @returns {PIXI.Polygon}
   */
  static getRayShape(distance, direction, width) {
    const d = canvas.dimensions;
    width *= d.distancePixels;
    const p00 = Ray.fromAngle(0, 0, Math.toRadians(direction - 90), width / 2).B;
    const p01 = Ray.fromAngle(0, 0, Math.toRadians(direction + 90), width / 2).B;
    let p10;
    let p11;

    // Grid ray
    if ( game.settings.get("core", "gridTemplates") ) {
      p10 = canvas.grid.getTranslatedPoint(p00, direction, distance);
      p11 = canvas.grid.getTranslatedPoint(p01, direction, distance);
    }

    // Euclidean ray
    else {
      distance *= d.distancePixels;
      direction = Math.toRadians(direction);
      p10 = Ray.fromAngle(p00.x, p00.y, direction, distance).B;
      p11 = Ray.fromAngle(p01.x, p01.y, direction, distance).B;
    }

    return new PIXI.Polygon(p00.x, p00.y, p10.x, p10.y, p11.x, p11.y, p01.x, p01.y);
  }

  /* -------------------------------------------- */

  /**
   * Update the displayed ruler tooltip text
   * @protected
   */
  _refreshRulerText() {
    const {distance, t} = this.document;
    const grid = canvas.grid;
    if ( t === "rect" ) {
      const {A: {x: x0, y: y0}, B: {x: x1, y: y1}} = this.ray;
      const dx = grid.measurePath([{x: x0, y: y0}, {x: x1, y: y0}]).distance;
      const dy = grid.measurePath([{x: x0, y: y0}, {x: x0, y: y1}]).distance;
      const w = Math.round(dx * 10) / 10;
      const h = Math.round(dy * 10) / 10;
      this.ruler.text = `${w}${grid.units} x ${h}${grid.units}`;
    } else {
      const r = Math.round(distance * 10) / 10;
      this.ruler.text = `${r}${grid.units}`;
    }
    this.ruler.position.set(this.ray.dx + 10, this.ray.dy + 5);
  }

  /* -------------------------------------------- */

  /**
   * Highlight the grid squares which should be shown under the area of effect
   */
  highlightGrid() {
    // Clear the existing highlight layer
    canvas.interface.grid.clearHighlightLayer(this.highlightId);

    // Highlight colors
    const border = this.document.borderColor;
    const color = this.document.fillColor;

    // If we are in grid-less mode, highlight the shape directly
    if ( canvas.grid.type === CONST.GRID_TYPES.GRIDLESS ) {
      const shape = this._getGridHighlightShape();
      canvas.interface.grid.highlightPosition(this.highlightId, {border, color, shape});
    }

    // Otherwise, highlight specific grid positions
    else {
      const positions = this._getGridHighlightPositions();
      for ( const {x, y} of positions ) {
        canvas.interface.grid.highlightPosition(this.highlightId, {x, y, border, color});
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the shape to highlight on a Scene which uses grid-less mode.
   * @returns {PIXI.Polygon|PIXI.Circle|PIXI.Rectangle}
   * @protected
   */
  _getGridHighlightShape() {
    const shape = this.shape.clone();
    if ( "points" in shape ) {
      shape.points = shape.points.map((p, i) => {
        if ( i % 2 ) return this.y + p;
        else return this.x + p;
      });
    } else {
      shape.x += this.x;
      shape.y += this.y;
    }
    return shape;
  }

  /* -------------------------------------------- */

  /**
   * Get an array of points which define top-left grid spaces to highlight for square or hexagonal grids.
   * @returns {Point[]}
   * @protected
   */
  _getGridHighlightPositions() {
    const grid = canvas.grid;
    const {x: ox, y: oy} = this.document;
    const shape = this.shape;
    const bounds = shape.getBounds();
    bounds.x += ox;
    bounds.y += oy;
    bounds.fit(canvas.dimensions.rect);
    bounds.pad(1);

    // Identify grid space that have their center points covered by the template shape
    const positions = [];
    const [i0, j0, i1, j1] = grid.getOffsetRange(bounds);
    for ( let i = i0; i < i1; i++ ) {
      for ( let j = j0; j < j1; j++ ) {
        const offset = {i, j};
        const {x: cx, y: cy} = grid.getCenterPoint(offset);

        // If the origin of the template is a grid space center, this grid space is highlighted
        let covered = (Math.max(Math.abs(cx - ox), Math.abs(cy - oy)) < 1);
        if ( !covered ) {
          for ( let dx = -0.5; dx <= 0.5; dx += 0.5 ) {
            for ( let dy = -0.5; dy <= 0.5; dy += 0.5 ) {
              if ( shape.contains(cx - ox + dx, cy - oy + dy) ) {
                covered = true;
                break;
              }
            }
          }
        }
        if ( !covered ) continue;
        positions.push(grid.getTopLeftPoint(offset));
      }
    }
    return positions;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @override */
  async rotate(angle, snap) {
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", {localize: true});
      return this;
    }
    const direction = this._updateRotation({angle, snap});
    await this.document.update({direction});
    return this;
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Incremental Refresh
    this.renderFlags.set({
      redraw: "texture" in changed,
      refreshState: ("sort" in changed) || ("hidden" in changed),
      refreshPosition: ("x" in changed) || ("y" in changed),
      refreshElevation: "elevation" in changed,
      refreshShape: ["t", "angle", "direction", "distance", "width"].some(k => k in changed),
      refreshTemplate: "borderColor" in changed,
      refreshGrid: ("borderColor" in changed) || ("fillColor" in changed)
    });
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @override */
  _canControl(user, event) {
    if ( !this.layer.active || this.isPreview ) return false;
    return user.isGM || (user === this.document.author);
  }

  /** @inheritdoc */
  _canHUD(user, event) {
    return this.isOwner; // Allow template owners to right-click
  }

  /** @inheritdoc */
  _canConfigure(user, event) {
    return false; // Double-right does nothing
  }

  /** @override */
  _canView(user, event) {
    return this._canControl(user, event);
  }

  /** @inheritdoc */
  _onClickRight(event) {
    this.document.update({hidden: !this.document.hidden});
    if ( !this._propagateRightClick(event) ) event.stopPropagation();
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get borderColor() {
    const msg = "MeasuredTemplate#borderColor has been deprecated. Use MeasuredTemplate#document#borderColor instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.document.borderColor.valueOf();
  }

  /* -------------------------------------------- */


  /**
   * @deprecated since v12
   * @ignore
   */
  get fillColor() {
    const msg = "MeasuredTemplate#fillColor has been deprecated. Use MeasuredTemplate#document#fillColor instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.document.fillColor.valueOf();
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get owner() {
    const msg = "MeasuredTemplate#owner has been deprecated. Use MeasuredTemplate#isOwner instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.isOwner;
  }
}

/**
 * A Tile is an implementation of PlaceableObject which represents a static piece of artwork or prop within the Scene.
 * Tiles are drawn inside the {@link TilesLayer} container.
 * @category - Canvas
 *
 * @see {@link TileDocument}
 * @see {@link TilesLayer}
 */
class Tile extends PlaceableObject {

  /* -------------------------------------------- */
  /*  Attributes                                  */
  /* -------------------------------------------- */

  /** @inheritdoc */
  static embeddedName = "Tile";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshTransform", "refreshMesh", "refreshElevation", "refreshVideo"], alias: true},
    refreshState: {propagate: ["refreshPerception"]},
    refreshTransform: {propagate: ["refreshPosition", "refreshRotation", "refreshSize"], alias: true},
    refreshPosition: {propagate: ["refreshPerception"]},
    refreshRotation: {propagate: ["refreshPerception", "refreshFrame"]},
    refreshSize: {propagate: ["refreshPosition", "refreshFrame"]},
    refreshMesh: {},
    refreshFrame: {},
    refreshElevation: {propagate: ["refreshPerception"]},
    refreshPerception: {},
    refreshVideo: {},
    /** @deprecated since v12 */
    refreshShape: {
      propagate: ["refreshTransform", "refreshMesh", "refreshElevation"],
      deprecated: {since: 12, until: 14, alias: true}
    }
  };

  /**
   * The Tile border frame
   * @type {PIXI.Container}
   */
  frame;

  /**
   * The primary tile image texture
   * @type {PIXI.Texture}
   */
  texture;

  /**
   * A Tile background which is displayed if no valid image texture is present
   * @type {PIXI.Graphics}
   */
  bg;

  /**
   * A reference to the SpriteMesh which displays this Tile in the PrimaryCanvasGroup.
   * @type {PrimarySpriteMesh}
   */
  mesh;

  /**
   * A flag to capture whether this Tile has an unlinked video texture
   * @type {boolean}
   */
  #unlinkedVideo = false;

  /**
   * Video options passed by the HUD
   * @type {object}
   */
  #hudVideoOptions = {
    playVideo: undefined,
    offset: undefined
  };

  /* -------------------------------------------- */

  /**
   * Get the native aspect ratio of the base texture for the Tile sprite
   * @type {number}
   */
  get aspectRatio() {
    if ( !this.texture ) return 1;
    let tex = this.texture.baseTexture;
    return (tex.width / tex.height);
  }

  /* -------------------------------------------- */

  /** @override */
  get bounds() {
    let {x, y, width, height, texture, rotation} = this.document;

    // Adjust top left coordinate and dimensions according to scale
    if ( texture.scaleX !== 1 ) {
      const w0 = width;
      width *= Math.abs(texture.scaleX);
      x += (w0 - width) / 2;
    }
    if ( texture.scaleY !== 1 ) {
      const h0 = height;
      height *= Math.abs(texture.scaleY);
      y += (h0 - height) / 2;
    }

    // If the tile is rotated, return recomputed bounds according to rotation
    if ( rotation !== 0 ) return PIXI.Rectangle.fromRotation(x, y, width, height, Math.toRadians(rotation)).normalize();

    // Normal case
    return new PIXI.Rectangle(x, y, width, height).normalize();
  }

  /* -------------------------------------------- */

  /**
   * The HTML source element for the primary Tile texture
   * @type {HTMLImageElement|HTMLVideoElement}
   */
  get sourceElement() {
    return this.texture?.baseTexture.resource.source;
  }

  /* -------------------------------------------- */

  /**
   * Does this Tile depict an animated video texture?
   * @type {boolean}
   */
  get isVideo() {
    const source = this.sourceElement;
    return source?.tagName === "VIDEO";
  }

  /* -------------------------------------------- */

  /**
   * Is this Tile currently visible on the Canvas?
   * @type {boolean}
   */
  get isVisible() {
    return !this.document.hidden || game.user.isGM;
  }

  /* -------------------------------------------- */

  /**
   * Is this tile occluded?
   * @returns {boolean}
   */
  get occluded() {
    return this.mesh?.occluded ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Is the tile video playing?
   * @type {boolean}
   */
  get playing() {
    return this.isVideo && !this.sourceElement.paused;
  }

  /* -------------------------------------------- */

  /**
   * The effective volume at which this Tile should be playing, including the global ambient volume modifier
   * @type {number}
   */
  get volume() {
    return this.document.video.volume * game.settings.get("core", "globalAmbientVolume");
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @override */
  _overlapsSelection(rectangle) {
    if ( !this.frame ) return false;
    const localRectangle = new PIXI.Rectangle(
      rectangle.x - this.document.x,
      rectangle.y - this.document.y,
      rectangle.width,
      rectangle.height
    );
    return localRectangle.overlaps(this.frame.bounds);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Create a preview tile with a background texture instead of an image
   * @param {object} data     Initial data with which to create the preview Tile
   * @returns {PlaceableObject}
   */
  static createPreview(data) {
    data.width = data.height = 1;
    data.elevation = data.elevation ?? (ui.controls.control.foreground ? canvas.scene.foregroundElevation : 0);
    data.sort = Math.max(canvas.tiles.getMaxSort() + 1, 0);

    // Create a pending TileDocument
    const cls = getDocumentClass("Tile");
    const doc = new cls(data, {parent: canvas.scene});

    // Render the preview Tile object
    const tile = doc.object;
    tile.control({releaseOthers: false});
    tile.draw().then(() => {  // Swap the z-order of the tile and the frame
      tile.removeChild(tile.frame);
      tile.addChild(tile.frame);
    });
    return tile;
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options={}) {

    // Load Tile texture
    let texture;
    if ( this._original ) texture = this._original.texture?.clone();
    else if ( this.document.texture.src ) {
      texture = await loadTexture(this.document.texture.src, {fallback: "icons/svg/hazard.svg"});
    }

    // Manage video playback and clone texture for unlinked video
    let video = game.video.getVideoSource(texture);
    this.#unlinkedVideo = !!video && !this._original;
    if ( this.#unlinkedVideo ) {
      texture = await game.video.cloneTexture(video);
      video = game.video.getVideoSource(texture);
      if ( (this.document.getFlag("core", "randomizeVideo") !== false) && Number.isFinite(video.duration) ) {
        video.currentTime = Math.random() * video.duration;
      }
    }
    if ( !video ) this.#hudVideoOptions.playVideo = undefined;
    this.#hudVideoOptions.offset = undefined;
    this.texture = texture;

    // Draw the Token mesh
    if ( this.texture ) {
      this.mesh = canvas.primary.addTile(this);
      this.bg = undefined;
    }

    // Draw a placeholder background
    else {
      canvas.primary.removeTile(this);
      this.texture = this.mesh = null;
      this.bg = this.addChild(new PIXI.Graphics());
      this.bg.eventMode = "none";
    }

    // Control Border
    this.frame = this.addChild(this.#drawFrame());

    // Interactivity
    this.cursor = this.document.isOwner ? "pointer" : null;
  }

  /* -------------------------------------------- */

  /**
   * Create elements for the Tile border and handles
   * @returns {PIXI.Container}
   */
  #drawFrame() {
    const frame = new PIXI.Container();
    frame.eventMode = "passive";
    frame.bounds = new PIXI.Rectangle();
    frame.interaction = frame.addChild(new PIXI.Container());
    frame.interaction.hitArea = frame.bounds;
    frame.interaction.eventMode = "auto";
    frame.border = frame.addChild(new PIXI.Graphics());
    frame.border.eventMode = "none";
    frame.handle = frame.addChild(new ResizeHandle([1, 1]));
    frame.handle.eventMode = "static";
    return frame;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  clear(options) {
    if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Base texture destroyed for non preview video
    this.#unlinkedVideo = false;
    super.clear(options);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _destroy(options) {
    canvas.primary.removeTile(this);
    if ( this.texture ) {
      if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Base texture destroyed for non preview video
      this.texture = undefined;
      this.#unlinkedVideo = false;
    }
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @override */
  _applyRenderFlags(flags) {
    if ( flags.refreshState ) this._refreshState();
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshRotation ) this._refreshRotation();
    if ( flags.refreshSize ) this._refreshSize();
    if ( flags.refreshMesh ) this._refreshMesh();
    if ( flags.refreshFrame ) this._refreshFrame();
    if ( flags.refreshElevation ) this._refreshElevation();
    if ( flags.refreshPerception ) this.#refreshPerception();
    if ( flags.refreshVideo ) this._refreshVideo();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the position.
   * @protected
   */
  _refreshPosition() {
    const {x, y, width, height} = this.document;
    if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
    this.position.set(x, y);
    if ( !this.mesh ) {
      this.bg.position.set(width / 2, height / 2);
      this.bg.pivot.set(width / 2, height / 2);
      return;
    }
    this.mesh.position.set(x + (width / 2), y + (height / 2));
  }

  /* -------------------------------------------- */

  /**
   * Refresh the rotation.
   * @protected
   */
  _refreshRotation() {
    const rotation = this.document.rotation;
    if ( !this.mesh ) return this.bg.angle = rotation;
    this.mesh.angle = rotation;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the size.
   * @protected
   */
  _refreshSize() {
    const {width, height, texture: {fit, scaleX, scaleY}} = this.document;
    if ( !this.mesh ) return this.bg.clear().beginFill(0xFFFFFF, 0.5).drawRect(0, 0, width, height).endFill();
    this.mesh.resize(width, height, {fit, scaleX, scaleY});
  }

  /* -------------------------------------------- */

  /**
   * Refresh the displayed state of the Tile.
   * Updated when the tile interaction state changes, when it is hidden, or when its elevation changes.
   * @protected
   */
  _refreshState() {
    const {hidden, locked, elevation, sort} = this.document;
    this.visible = this.isVisible;
    this.alpha = this._getTargetAlpha();
    if ( this.bg ) this.bg.visible = this.layer.active;
    const colors = CONFIG.Canvas.dispositionColors;
    this.frame.border.tint = this.controlled ? (locked ? colors.HOSTILE : colors.CONTROLLED) : colors.INACTIVE;
    this.frame.border.visible = this.controlled || this.hover || this.layer.highlightObjects;
    this.frame.handle.visible = this.controlled && !locked;
    const foreground = this.layer.active && !!ui.controls.control.foreground;
    const overhead = elevation >= this.document.parent.foregroundElevation;
    const oldEventMode = this.eventMode;
    this.eventMode = overhead === foreground ? "static" : "none";
    if ( this.eventMode !== oldEventMode ) MouseInteractionManager.emulateMoveEvent();
    const zIndex = this.zIndex = this.controlled ? 2 : this.hover ? 1 : 0;
    if ( !this.mesh ) return;
    this.mesh.visible = this.visible;
    this.mesh.sort = sort;
    this.mesh.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.TILES;
    this.mesh.zIndex = zIndex;
    this.mesh.alpha = this.alpha * (hidden ? 0.5 : 1);
    this.mesh.hidden = hidden;
    this.mesh.restrictsLight = this.document.restrictions.light;
    this.mesh.restrictsWeather = this.document.restrictions.weather;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the appearance of the tile.
   * @protected
   */
  _refreshMesh() {
    if ( !this.mesh ) return;
    const {width, height, alpha, occlusion, texture} = this.document;
    const {anchorX, anchorY, fit, scaleX, scaleY, tint, alphaThreshold} = texture;
    this.mesh.anchor.set(anchorX, anchorY);
    this.mesh.resize(width, height, {fit, scaleX, scaleY});
    this.mesh.unoccludedAlpha = alpha;
    this.mesh.occludedAlpha = occlusion.alpha;
    this.mesh.occlusionMode = occlusion.mode;
    this.mesh.hoverFade = this.mesh.isOccludable;
    this.mesh.tint = tint;
    this.mesh.textureAlphaThreshold = alphaThreshold;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the elevation.
   * @protected
   */
  _refreshElevation() {
    if ( !this.mesh ) return;
    this.mesh.elevation = this.document.elevation;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the tiles.
   */
  #refreshPerception() {
    if ( !this.mesh ) return;
    canvas.perception.update({refreshOcclusionStates: true});
  }

  /* -------------------------------------------- */

  /**
   * Refresh the border frame that encloses the Tile.
   * @protected
   */
  _refreshFrame() {
    const thickness = CONFIG.Canvas.objectBorderThickness;

    // Update the frame bounds
    const {width, height, rotation} = this.document;
    const bounds = this.frame.bounds;
    bounds.x = 0;
    bounds.y = 0;
    bounds.width = width;
    bounds.height = height;
    bounds.rotate(Math.toRadians(rotation));
    const minSize = thickness * 0.25;
    if ( bounds.width < minSize ) {
      bounds.x -= ((minSize - bounds.width) / 2);
      bounds.width = minSize;
    }
    if ( bounds.height < minSize ) {
      bounds.y -= ((minSize - bounds.height) / 2);
      bounds.height = minSize;
    }
    MouseInteractionManager.emulateMoveEvent();

    // Draw the border
    const border = this.frame.border;
    border.clear();
    border.lineStyle({width: thickness, color: 0x000000, join: PIXI.LINE_JOIN.ROUND, alignment: 0.75})
      .drawShape(bounds);
    border.lineStyle({width: thickness / 2, color: 0xFFFFFF, join: PIXI.LINE_JOIN.ROUND, alignment: 1})
      .drawShape(bounds);

    // Draw the handle
    this.frame.handle.refresh(bounds);
  }

  /* -------------------------------------------- */

  /**
   * Refresh changes to the video playback state.
   * @protected
   */
  _refreshVideo() {
    if ( !this.texture || !this.#unlinkedVideo ) return;
    const video = game.video.getVideoSource(this.texture);
    if ( !video ) return;
    const playOptions = {...this.document.video, volume: this.volume};
    playOptions.playing = (this.#hudVideoOptions.playVideo ?? playOptions.autoplay);
    playOptions.offset = this.#hudVideoOptions.offset;
    this.#hudVideoOptions.offset = undefined;
    game.video.play(video, playOptions);

    // Refresh HUD if necessary
    if ( this.hasActiveHUD ) this.layer.hud.render();
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    const restrictionsChanged = ("restrictions" in changed) && !foundry.utils.isEmpty(changed.restrictions);

    // Refresh the Drawing
    this.renderFlags.set({
      redraw: ("texture" in changed) && ("src" in changed.texture),
      refreshState: ("sort" in changed) || ("hidden" in changed) || ("locked" in changed) || restrictionsChanged,
      refreshPosition: ("x" in changed) || ("y" in changed),
      refreshRotation: "rotation" in changed,
      refreshSize: ("width" in changed) || ("height" in changed),
      refreshMesh: ("alpha" in changed) || ("occlusion" in changed) || ("texture" in changed),
      refreshElevation: "elevation" in changed,
      refreshPerception: ("occlusion" in changed) && ("mode" in changed.occlusion),
      refreshVideo: ("video" in changed) || ("playVideo" in options) || ("offset" in options)
    });

    // Set the video options
    if ( "playVideo" in options ) this.#hudVideoOptions.playVideo = options.playVideo;
    if ( "offset" in options ) this.#hudVideoOptions.offset = options.offset;
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners() {
    super.activateListeners();
    this.frame.handle.off("pointerover").off("pointerout")
      .on("pointerover", this._onHandleHoverIn.bind(this))
      .on("pointerout", this._onHandleHoverOut.bind(this));
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onClickLeft(event) {
    if ( event.target === this.frame.handle ) {
      event.interactionData.dragHandle = true;
      event.stopPropagation();
      return;
    }
    return super._onClickLeft(event);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftStart(event) {
    if ( event.interactionData.dragHandle ) return this._onHandleDragStart(event);
    return super._onDragLeftStart(event);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftMove(event) {
    if ( event.interactionData.dragHandle ) return this._onHandleDragMove(event);
    super._onDragLeftMove(event);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftDrop(event) {
    if ( event.interactionData.dragHandle ) return this._onHandleDragDrop(event);
    return super._onDragLeftDrop(event);
  }

  /* -------------------------------------------- */
  /*  Resize Handling                             */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftCancel(event) {
    if ( event.interactionData.dragHandle ) return this._onHandleDragCancel(event);
    return super._onDragLeftCancel(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-over event on a control handle
   * @param {PIXI.FederatedEvent} event   The mouseover event
   * @protected
   */
  _onHandleHoverIn(event) {
    const handle = event.target;
    handle?.scale.set(1.5, 1.5);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-out event on a control handle
   * @param {PIXI.FederatedEvent} event   The mouseout event
   * @protected
   */
  _onHandleHoverOut(event) {
    const handle = event.target;
    handle?.scale.set(1.0, 1.0);
  }

  /* -------------------------------------------- */

  /**
   * Handle the beginning of a drag event on a resize handle.
   * @param {PIXI.FederatedEvent} event   The mousedown event
   * @protected
   */
  _onHandleDragStart(event) {
    const handle = this.frame.handle;
    const aw = this.document.width;
    const ah = this.document.height;
    const x0 = this.document.x + (handle.offset[0] * aw);
    const y0 = this.document.y + (handle.offset[1] * ah);
    event.interactionData.origin = {x: x0, y: y0, width: aw, height: ah};
  }

  /* -------------------------------------------- */

  /**
   * Handle mousemove while dragging a tile scale handler
   * @param {PIXI.FederatedEvent} event   The mousemove event
   * @protected
   */
  _onHandleDragMove(event) {
    canvas._onDragCanvasPan(event);
    const interaction = event.interactionData;
    if ( !event.shiftKey ) interaction.destination = this.layer.getSnappedPoint(interaction.destination);
    const d = this.#getResizedDimensions(event);
    this.document.x = d.x;
    this.document.y = d.y;
    this.document.width = d.width;
    this.document.height = d.height;
    this.document.rotation = 0;

    // Mirror horizontally or vertically
    this.document.texture.scaleX = d.sx;
    this.document.texture.scaleY = d.sy;
    this.renderFlags.set({refreshTransform: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle mouseup after dragging a tile scale handler
   * @param {PIXI.FederatedEvent} event   The mouseup event
   * @protected
   */
  _onHandleDragDrop(event) {
    const interaction = event.interactionData;
    interaction.resetDocument = false;
    if ( !event.shiftKey ) interaction.destination = this.layer.getSnappedPoint(interaction.destination);
    const d = this.#getResizedDimensions(event);
    this.document.update({
      x: d.x, y: d.y, width: d.width, height: d.height, "texture.scaleX": d.sx, "texture.scaleY": d.sy
    }).then(() => this.renderFlags.set({refreshTransform: true}));
  }

  /* -------------------------------------------- */

  /**
   * Get resized Tile dimensions
   * @param {PIXI.FederatedEvent} event
   * @returns {{x: number, y: number, width: number, height: number, sx: number, sy: number}}
   */
  #getResizedDimensions(event) {
    const o = this.document._source;
    const {origin, destination} = event.interactionData;

    // Identify the new width and height as positive dimensions
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;
    let w = Math.abs(o.width) + dx;
    let h = Math.abs(o.height) + dy;

    // Constrain the aspect ratio using the ALT key
    if ( event.altKey && this.texture?.valid ) {
      const ar = this.texture.width / this.texture.height;
      if ( Math.abs(w) > Math.abs(h) ) h = w / ar;
      else w = h * ar;
    }
    const {x, y, width, height} = new PIXI.Rectangle(o.x, o.y, w, h).normalize();

    // Comparing destination coord and source coord to apply mirroring and append to nr
    const sx = (Math.sign(destination.x - o.x) || 1) * o.texture.scaleX;
    const sy = (Math.sign(destination.y - o.y) || 1) * o.texture.scaleY;
    return {x, y, width, height, sx, sy};
  }

  /* -------------------------------------------- */

  /**
   * Handle cancellation of a drag event for one of the resizing handles
   * @param {PIXI.FederatedEvent} event   The mouseup event
   * @protected
   */
  _onHandleDragCancel(event) {
    if ( event.interactionData.resetDocument !== false ) {
      this.document.reset();
      this.renderFlags.set({refreshTransform: true});
    }
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get isRoof() {
    const msg = "Tile#isRoof has been deprecated without replacement.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.document.roof;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  testOcclusion(...args) {
    const msg = "Tile#testOcclusion has been deprecated in favor of PrimaryCanvasObject#testOcclusion";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return this.mesh?.testOcclusion(...args) ?? false;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  containsPixel(...args) {
    const msg = "Tile#containsPixel has been deprecated in favor of PrimaryCanvasObject#containsPixel"
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return this.mesh?.containsPixel(...args) ?? false;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  getPixelAlpha(...args) {
    const msg = "Tile#getPixelAlpha has been deprecated in favor of PrimaryCanvasObject#getPixelAlpha"
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return this.mesh?.getPixelAlpha(...args) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  _getAlphaBounds() {
    const msg = "Tile#_getAlphaBounds has been deprecated";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    return this.mesh?._getAlphaBounds();
  }
}

/**
 * A Token is an implementation of PlaceableObject which represents an Actor within a viewed Scene on the game canvas.
 * @category - Canvas
 * @see {TokenDocument}
 * @see {TokenLayer}
 */
class Token extends PlaceableObject {
  constructor(document) {
    super(document);
    this.#initialize();
  }

  /** @inheritdoc */
  static embeddedName = "Token";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    redrawEffects: {},
    refresh: {propagate: ["refreshState", "refreshTransform", "refreshMesh", "refreshNameplate", "refreshElevation", "refreshRingVisuals"], alias: true},
    refreshState: {propagate: ["refreshVisibility", "refreshTarget"]},
    refreshVisibility: {},
    refreshTransform: {propagate: ["refreshPosition", "refreshRotation", "refreshSize"], alias: true},
    refreshPosition: {},
    refreshRotation: {},
    refreshSize: {propagate: ["refreshPosition", "refreshShape", "refreshBars", "refreshEffects", "refreshNameplate", "refreshTarget", "refreshTooltip"]},
    refreshElevation: {propagate: ["refreshTooltip"]},
    refreshMesh: {propagate: ["refreshShader"]},
    refreshShader: {},
    refreshShape: {propagate: ["refreshVisibility", "refreshPosition", "refreshBorder", "refreshEffects"]},
    refreshBorder: {},
    refreshBars: {},
    refreshEffects: {},
    refreshNameplate: {},
    refreshTarget: {},
    refreshTooltip: {},
    refreshRingVisuals: {},
    /** @deprecated since v12 Stable 4 */
    recoverFromPreview: {deprecated: {since: 12, until: 14}}
  };

  /**
   * Used in {@link Token#_renderDetectionFilter}.
   * @type {[detectionFilter: PIXI.Filter|null]}
   */
  static #DETECTION_FILTER_ARRAY = [null];

  /**
   * The shape of this token.
   * @type {PIXI.Rectangle|PIXI.Polygon|PIXI.Circle}
   */
  shape;

  /**
   * Defines the filter to use for detection.
   * @param {PIXI.Filter|null} filter
   */
  detectionFilter = null;

  /**
   * A Graphics instance which renders the border frame for this Token inside the GridLayer.
   * @type {PIXI.Graphics}
   */
  border;

  /**
   * The effects icons of temporary ActiveEffects that are applied to the Actor of this Token.
   * @type {PIXI.Container}
   */
  effects;

  /**
   * The attribute bars of this Token.
   * @type {PIXI.Container}
   */
  bars;

  /**
   * The tooltip text of this Token, which contains its elevation.
   * @type {PreciseText}
   */
  tooltip;

  /**
   * The target marker, which indicates that this Token is targeted by this User or others.
   * @type {PIXI.Graphics}
   */
  target;

  /**
   * The nameplate of this Token, which displays its name.
   * @type {PreciseText}
   */
  nameplate;

  /**
   * Track the set of User documents which are currently targeting this Token
   * @type {Set<User>}
   */
  targeted = new Set([]);

  /**
   * A reference to the SpriteMesh which displays this Token in the PrimaryCanvasGroup.
   * @type {PrimarySpriteMesh}
   */
  mesh;

  /**
   * Renders the mesh of this Token with ERASE blending in the Token.
   * @type {PIXI.DisplayObject}
   */
  voidMesh;

  /**
   * Renders the mesh of with the detection filter.
   * @type {PIXI.DisplayObject}
   */
  detectionFilterMesh;

  /**
   * The texture of this Token, which is used by its mesh.
   * @type {PIXI.Texture}
   */
  texture;

  /**
   * A reference to the VisionSource object which defines this vision source area of effect.
   * This is undefined if the Token does not provide an active source of vision.
   * @type {PointVisionSource}
   */
  vision;

  /**
   * A reference to the LightSource object which defines this light source area of effect.
   * This is undefined if the Token does not provide an active source of light.
   * @type {PointLightSource}
   */
  light;

  /**
   * An Object which records the Token's prior velocity dx and dy.
   * This can be used to determine which direction a Token was previously moving.
   * @type {{dx: number, dy: number, ox: number, oy: number}}
   */
  #priorMovement;

  /**
   * The Token central coordinate, adjusted for its most recent movement vector.
   * @type {Point}
   */
  #adjustedCenter;

  /**
   * @typedef {Point} TokenPosition
   * @property {number} rotation  The token's last valid rotation.
   */

  /**
   * The Token's most recent valid position and rotation.
   * @type {TokenPosition}
   */
  #validPosition;

  /**
   * A flag to capture whether this Token has an unlinked video texture.
   * @type {boolean}
   */
  #unlinkedVideo = false;

  /**
   * @typedef {object} TokenAnimationData
   * @property {number} x                        The x position in pixels
   * @property {number} y                        The y position in pixels
   * @property {number} width                    The width in grid spaces
   * @property {number} height                   The height in grid spaces
   * @property {number} alpha                    The alpha value
   * @property {number} rotation                 The rotation in degrees
   * @property {object} texture                  The texture data
   * @property {string} texture.src              The texture file path
   * @property {number} texture.anchorX          The texture anchor X
   * @property {number} texture.anchorY          The texture anchor Y
   * @property {number} texture.scaleX           The texture scale X
   * @property {number} texture.scaleY           The texture scale Y
   * @property {Color} texture.tint              The texture tint
   * @property {object} ring                     The ring data
   * @property {object} ring.subject             The ring subject data
   * @property {string} ring.subject.texture     The ring subject texture
   * @property {number} ring.subject.scale       The ring subject scale
   */

  /**
   * The current animation data of this Token.
   * @type {TokenAnimationData}
   */
  #animationData;

  /**
   * The prior animation data of this Token.
   * @type {TokenAnimationData}
   */
  #priorAnimationData;

  /**
   * A map of effects id and their filters applied on this token placeable.
   * @type {Map<string: effectId, AbstractBaseFilter: filter>}
   */
  #filterEffects = new Map();

  /**
   * @typedef {object} TokenAnimationContext
   * @property {string|symbol} name              The name of the animation
   * @property {Partial<TokenAnimationData>} to  The final animation state
   * @property {number} duration                 The duration of the animation
   * @property {number} time                     The current time of the animation
   * @property {((context: TokenAnimationContext) => Promise<void>)[]} preAnimate
   *   Asynchronous functions that are executed before the animation starts
   * @property {((context: TokenAnimationContext) => void)[]} postAnimate
   *   Synchronous functions that are executed after the animation ended.
   *   They may be executed before the preAnimate functions have finished  if the animation is terminated.
   * @property {((context: TokenAnimationContext) => void)[]} onAnimate
   *   Synchronous functions that are executed each frame after `ontick` and before {@link Token#_onAnimationUpdate}.
   * @property {Promise<boolean>} [promise]
   *   The promise of the animation, which resolves to true if the animation
   *   completed, to false if it was terminated, and rejects if an error occurred.
   *   Undefined in the first frame (at time 0) of the animation.
   */

  /**
   * The current animations of this Token.
   * @type {Map<string, TokenAnimationContext>}
   */
  get animationContexts() {
    return this.#animationContexts;
  }

  #animationContexts = new Map();

  /**
   * A TokenRing instance which is used if this Token applies a dynamic ring.
   * This property is null if the Token does not use a dynamic ring.
   * @type {foundry.canvas.tokens.TokenRing|null}
   */
  get ring() {
    return this.#ring;
  }

  #ring;

  /**
   * A convenience boolean to test whether the Token is using a dynamic ring.
   * @type {boolean}
   */
  get hasDynamicRing() {
    return this.ring instanceof foundry.canvas.tokens.TokenRing;
  }

  /* -------------------------------------------- */
  /*  Initialization                              */
  /* -------------------------------------------- */

  /**
   * Establish an initial velocity of the token based on its direction of facing.
   * Assume the Token made some prior movement towards the direction that it is currently facing.
   */
  #initialize() {

    // Initialize prior movement
    const {x, y, rotation} = this.document;
    const r = Ray.fromAngle(x, y, Math.toRadians(rotation + 90), canvas.dimensions.size);

    // Initialize valid position
    this.#validPosition = {x, y, rotation};
    this.#priorMovement = {dx: r.dx, dy: r.dy, ox: Math.sign(r.dx), oy: Math.sign(r.dy)};
    this.#adjustedCenter = this.getMovementAdjustedPoint(this.center);

    // Initialize animation data
    this.#animationData = this._getAnimationData();
    this.#priorAnimationData = foundry.utils.deepClone(this.#animationData);
  }

  /* -------------------------------------------- */

  /**
   * Initialize a TokenRing instance for this Token, if a dynamic ring is enabled.
   */
  #initializeRing() {

    // Construct a TokenRing instance
    if ( this.document.ring.enabled ) {
      if ( !this.hasDynamicRing ) {
        const cls = CONFIG.Token.ring.ringClass;
        if ( !foundry.utils.isSubclass(cls, foundry.canvas.tokens.TokenRing) ) {
          throw new Error("The configured CONFIG.Token.ring.ringClass is not a TokenRing subclass.");
        }
        this.#ring = new cls(this);
      }
      this.#ring.configure(this.mesh);
      return;
    }

    // Deactivate a prior TokenRing instance
    if ( this.hasDynamicRing ) this.#ring.clear();
    this.#ring = null;
  }

  /* -------------------------------------------- */
  /*  Permission Attributes
  /* -------------------------------------------- */

  /**
   * A convenient reference to the Actor object associated with the Token embedded document.
   * @returns {Actor|null}
   */
  get actor() {
    return this.document.actor;
  }

  /* -------------------------------------------- */

  /**
   * A boolean flag for whether the current game User has observer permission for the Token
   * @type {boolean}
   */
  get observer() {
    return game.user.isGM || !!this.actor?.testUserPermission(game.user, "OBSERVER");
  }

  /* -------------------------------------------- */

  /**
   * Convenience access to the token's nameplate string
   * @type {string}
   */
  get name() {
    return this.document.name;
  }

  /* -------------------------------------------- */
  /*  Rendering Attributes
  /* -------------------------------------------- */

  /** @override */
  get bounds() {
    const {x, y} = this.document;
    const {width, height} = this.getSize();
    return new PIXI.Rectangle(x, y, width, height);
  }

  /* -------------------------------------------- */

  /**
   * Translate the token's grid width into a pixel width based on the canvas size
   * @type {number}
   */
  get w() {
    return this.getSize().width;
  }

  /* -------------------------------------------- */

  /**
   * Translate the token's grid height into a pixel height based on the canvas size
   * @type {number}
   */
  get h() {
    return this.getSize().height;
  }

  /* -------------------------------------------- */

  /**
   * The Token's current central position
   * @type {PIXI.Point}
   */
  get center() {
    const {x, y} = this.getCenterPoint();
    return new PIXI.Point(x, y);
  }

  /* -------------------------------------------- */

  /**
   * The Token's central position, adjusted in each direction by one or zero pixels to offset it relative to walls.
   * @type {Point}
   */
  getMovementAdjustedPoint(point, {offsetX, offsetY}={}) {
    const x = Math.round(point.x);
    const y = Math.round(point.y);
    const r = new PIXI.Rectangle(x, y, 0, 0);

    // Verify whether the current position overlaps an edge
    const edges = [];
    for ( const edge of canvas.edges.values() ) {
      if ( !edge.move ) continue; // Non-blocking movement
      if ( r.overlaps(edge.bounds) && (foundry.utils.orient2dFast(edge.a, edge.b, {x, y}) === 0) ) edges.push(edge);
    }
    if ( edges.length ) {
      const {ox, oy} = this.#priorMovement;
      return {x: x - (offsetX ?? ox), y: y - (offsetY ?? oy)};
    }
    return {x, y};
  }

  /* -------------------------------------------- */

  /**
   * The HTML source element for the primary Tile texture
   * @type {HTMLImageElement|HTMLVideoElement}
   */
  get sourceElement() {
    return this.texture?.baseTexture.resource.source;
  }

  /* -------------------------------------------- */

  /** @override */
  get sourceId() {
    let id = `${this.document.documentName}.${this.document.id}`;
    if ( this.isPreview ) id += ".preview";
    return id;
  }

  /* -------------------------------------------- */

  /**
   * Does this Tile depict an animated video texture?
   * @type {boolean}
   */
  get isVideo() {
    const source = this.sourceElement;
    return source?.tagName === "VIDEO";
  }

  /* -------------------------------------------- */
  /*  State Attributes
  /* -------------------------------------------- */

  /**
   * An indicator for whether or not this token is currently involved in the active combat encounter.
   * @type {boolean}
   */
  get inCombat() {
    return this.document.inCombat;
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to a Combatant that represents this Token, if one is present in the current encounter.
   * @type {Combatant|null}
   */
  get combatant() {
    return this.document.combatant;
  }

  /* -------------------------------------------- */

  /**
   * An indicator for whether the Token is currently targeted by the active game User
   * @type {boolean}
   */
  get isTargeted() {
    return this.targeted.has(game.user);
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the detection modes array.
   * @type {[object]}
   */
  get detectionModes() {
    return this.document.detectionModes;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the Token is visible to the calling user's perspective.
   * Hidden Tokens are only displayed to GM Users.
   * Non-hidden Tokens are always visible if Token Vision is not required.
   * Controlled tokens are always visible.
   * All Tokens are visible to a GM user if no Token is controlled.
   *
   * @see {CanvasVisibility#testVisibility}
   * @type {boolean}
   */
  get isVisible() {
    // Clear the detection filter
    this.detectionFilter = null;

    // Only GM users can see hidden tokens
    const gm = game.user.isGM;
    if ( this.document.hidden && !gm ) return false;

    // Some tokens are always visible
    if ( !canvas.visibility.tokenVision ) return true;
    if ( this.controlled ) return true;

    // Otherwise, test visibility against current sight polygons
    if ( this.vision?.active ) return true;
    const {width, height} = this.getSize();
    const tolerance = Math.min(width, height) / 4;
    return canvas.visibility.testVisibility(this.center, {tolerance, object: this});
  }

  /* -------------------------------------------- */

  /**
   * The animation name used for Token movement
   * @type {string}
   */
  get animationName() {
    return `${this.objectId}.animate`;
  }

  /* -------------------------------------------- */
  /*  Lighting and Vision Attributes
  /* -------------------------------------------- */

  /**
   * Test whether the Token has sight (or blindness) at any radius
   * @type {boolean}
   */
  get hasSight() {
    return this.document.sight.enabled;
  }

  /* -------------------------------------------- */

  /**
   * Does this Token actively emit light given its properties and the current darkness level of the Scene?
   * @returns {boolean}
   * @protected
   */
  _isLightSource() {
    const {hidden, light} = this.document;
    if ( hidden ) return false;
    if ( !(light.dim || light.bright) ) return false;
    const darkness = canvas.darknessLevel;
    if ( !darkness.between(light.darkness.min, light.darkness.max)) return false;
    return !this.document.hasStatusEffect(CONFIG.specialStatusEffects.BURROW);
  }

  /* -------------------------------------------- */

  /**
   * Does this Ambient Light actively emit darkness given
   * its properties and the current darkness level of the Scene?
   * @type {boolean}
   */
  get emitsDarkness() {
    return this.document.light.negative && this._isLightSource();
  }

  /* -------------------------------------------- */

  /**
   * Does this Ambient Light actively emit light given
   * its properties and the current darkness level of the Scene?
   * @type {boolean}
   */
  get emitsLight() {
    return !this.document.light.negative && this._isLightSource();
  }

  /* -------------------------------------------- */

  /**
   * Test whether the Token uses a limited angle of vision or light emission.
   * @type {boolean}
   */
  get hasLimitedSourceAngle() {
    const doc = this.document;
    return (this.hasSight && (doc.sight.angle !== 360)) || (this._isLightSource() && (doc.light.angle !== 360));
  }

  /* -------------------------------------------- */

  /**
   * Translate the token's dim light distance in units into a radius in pixels.
   * @type {number}
   */
  get dimRadius() {
    return this.getLightRadius(this.document.light.dim);
  }

  /* -------------------------------------------- */

  /**
   * Translate the token's bright light distance in units into a radius in pixels.
   * @type {number}
   */
  get brightRadius() {
    return this.getLightRadius(this.document.light.bright);
  }

  /* -------------------------------------------- */

  /**
   * The maximum radius in pixels of the light field
   * @type {number}
   */
  get radius() {
    return Math.max(Math.abs(this.dimRadius), Math.abs(this.brightRadius));
  }

  /* -------------------------------------------- */

  /**
   * The range of this token's light perception in pixels.
   * @type {number}
   */
  get lightPerceptionRange() {
    const mode = this.document.detectionModes.find(m => m.id === "lightPerception");
    return mode?.enabled ? this.getLightRadius(mode.range ?? Infinity) : 0;
  }

  /* -------------------------------------------- */

  /**
   * Translate the token's vision range in units into a radius in pixels.
   * @type {number}
   */
  get sightRange() {
    return this.getLightRadius(this.document.sight.range ?? Infinity);
  }

  /* -------------------------------------------- */

  /**
   * Translate the token's maximum vision range that takes into account lights.
   * @type {number}
   */
  get optimalSightRange() {
    let lightRadius = 0;
    const mode = this.document.detectionModes.find(m => m.id === "lightPerception");
    if ( mode?.enabled ) {
      lightRadius = Math.max(this.document.light.bright, this.document.light.dim);
      lightRadius = Math.min(lightRadius, mode.range ?? Infinity);
    }
    return this.getLightRadius(Math.max(this.document.sight.range ?? Infinity, lightRadius));
  }

  /* -------------------------------------------- */

  /**
   * Update the light and vision source objects associated with this Token.
   * @param {object} [options={}]       Options which configure how perception sources are updated
   * @param {boolean} [options.deleted=false]       Indicate that this light and vision source has been deleted
   */
  initializeSources({deleted=false}={}) {
    this.#adjustedCenter = this.getMovementAdjustedPoint(this.center);
    this.initializeLightSource({deleted});
    this.initializeVisionSource({deleted});
  }

  /* -------------------------------------------- */

  /**
   * Update an emitted light source associated with this Token.
   * @param {object} [options={}]
   * @param {boolean} [options.deleted]    Indicate that this light source has been deleted.
   */
  initializeLightSource({deleted=false}={}) {
    const sourceId = this.sourceId;
    const wasLight = canvas.effects.lightSources.has(sourceId);
    const wasDarkness = canvas.effects.darknessSources.has(sourceId);
    const isDarkness = this.document.light.negative;
    const perceptionFlags = {
      refreshEdges: wasDarkness || isDarkness,
      initializeVision: wasDarkness || isDarkness,
      initializeLighting: wasDarkness || isDarkness,
      refreshLighting: true,
      refreshVision: true
    };

    // Remove the light source from the active collection
    if ( deleted || !this._isLightSource() ) {
      if ( !this.light ) return;
      if ( this.light.active ) canvas.perception.update(perceptionFlags);
      this.#destroyLightSource();
      return;
    }

    // Re-create the source if it switches darkness state
    if ( (wasLight && isDarkness) || (wasDarkness && !isDarkness) ) this.#destroyLightSource();

    // Create a light source if necessary
    this.light ??= this.#createLightSource();

    // Re-initialize source data and add to the active collection
    this.light.initialize(this._getLightSourceData());
    this.light.add();
    canvas.perception.update(perceptionFlags);
  }

  /* -------------------------------------------- */

  /**
   * Get the light source data.
   * @returns {LightSourceData}
   * @protected
   */
  _getLightSourceData() {
    const {x, y} = this.#adjustedCenter;
    const {elevation, rotation} = this.document;
    const d = canvas.dimensions;
    const lightDoc = this.document.light;
    return foundry.utils.mergeObject(lightDoc.toObject(false), {
      x, y, elevation, rotation,
      dim: Math.clamp(this.getLightRadius(lightDoc.dim), 0, d.maxR),
      bright: Math.clamp(this.getLightRadius(lightDoc.bright), 0, d.maxR),
      externalRadius: this.externalRadius,
      seed: this.document.getFlag("core", "animationSeed"),
      preview: this.isPreview,
      disabled: !this._isLightSource()
    });
  }

  /* -------------------------------------------- */

  /**
   * Update the VisionSource instance associated with this Token.
   * @param {object} [options]        Options which affect how the vision source is updated
   * @param {boolean} [options.deleted]   Indicate that this vision source has been deleted.
   */
  initializeVisionSource({deleted=false}={}) {

    // Remove a deleted vision source from the active collection
    if ( deleted || !this._isVisionSource() ) {
      if ( !this.vision ) return;
      if ( this.vision.active ) canvas.perception.update({
        initializeVisionModes: true,
        refreshVision: true,
        refreshLighting: true
      });
      this.#destroyVisionSource();
      return;
    }

    // Create a vision source if necessary
    const wasVision = !!this.vision;
    this.vision ??= this.#createVisionSource();

    // Re-initialize source data
    const previousActive = this.vision.active;
    const previousVisionMode = this.vision.visionMode;
    const blindedStates = this._getVisionBlindedStates();
    for ( const state in blindedStates ) this.vision.blinded[state] = blindedStates[state];
    this.vision.initialize(this._getVisionSourceData());
    this.vision.add();
    canvas.perception.update({
      initializeVisionModes: !wasVision
        || (this.vision.active !== previousActive)
        || (this.vision.visionMode !== previousVisionMode),
      refreshVision: true,
      refreshLighting: true
    });
  }

  /* -------------------------------------------- */

  /**
   * Returns a record of blinding state.
   * @returns {Record<string, boolean>}
   * @protected
   */
  _getVisionBlindedStates() {
    return {
      blind: this.document.hasStatusEffect(CONFIG.specialStatusEffects.BLIND),
      burrow: this.document.hasStatusEffect(CONFIG.specialStatusEffects.BURROW)
    };
  }

  /* -------------------------------------------- */

  /**
   * Get the vision source data.
   * @returns {VisionSourceData}
   * @protected
   */
  _getVisionSourceData() {
    const d = canvas.dimensions;
    const {x, y} = this.#adjustedCenter;
    const {elevation, rotation} = this.document;
    const sight = this.document.sight;
    return {
      x, y, elevation, rotation,
      radius: Math.clamp(this.sightRange, 0, d.maxR),
      lightRadius: Math.clamp(this.lightPerceptionRange, 0, d.maxR),
      externalRadius: this.externalRadius,
      angle: sight.angle,
      contrast: sight.contrast,
      saturation: sight.saturation,
      brightness: sight.brightness,
      attenuation: sight.attenuation,
      visionMode: sight.visionMode,
      color: sight.color,
      preview: this.isPreview,
      disabled: false
    };
  }

  /* -------------------------------------------- */

  /**
   * Test whether this Token is a viable vision source for the current User.
   * @returns {boolean}
   * @protected
   */
  _isVisionSource() {
    if ( !canvas.visibility.tokenVision || !this.hasSight ) return false;

    // Only display hidden tokens for the GM
    const isGM = game.user.isGM;
    if ( this.document.hidden && !isGM ) return false;

    // Always display controlled tokens which have vision
    if ( this.controlled ) return true;

    // Otherwise, vision is ignored for GM users
    if ( isGM ) return false;

    // If a non-GM user controls no other tokens with sight, display sight
    const canObserve = this.actor?.testUserPermission(game.user, "OBSERVER") ?? false;
    if ( !canObserve ) return false;
    return !this.layer.controlled.some(t => !t.document.hidden && t.hasSight);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Render the bound mesh detection filter.
   * Note: this method does not verify that the detection filter exists.
   * @param {PIXI.Renderer} renderer
   * @protected
   */
  _renderDetectionFilter(renderer) {
    if ( !this.mesh ) return;

    Token.#DETECTION_FILTER_ARRAY[0] = this.detectionFilter;

    // Rendering the mesh
    const originalFilters = this.mesh.filters;
    const originalTint = this.mesh.tint;
    const originalAlpha = this.mesh.worldAlpha;
    this.mesh.filters = Token.#DETECTION_FILTER_ARRAY;
    this.mesh.tint = 0xFFFFFF;
    this.mesh.worldAlpha = 1;
    this.mesh.pluginName = BaseSamplerShader.classPluginName;
    this.mesh.render(renderer);
    this.mesh.filters = originalFilters;
    this.mesh.tint = originalTint;
    this.mesh.worldAlpha = originalAlpha;
    this.mesh.pluginName = null;

    Token.#DETECTION_FILTER_ARRAY[0] = null;
  }

  /* -------------------------------------------- */

  /** @override */
  clear() {
    if ( this.mesh ) {
      this.mesh.texture = PIXI.Texture.EMPTY;
      this.mesh.visible = false;
    }
    if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy(); // Destroy base texture if the token has an unlinked video
    this.#unlinkedVideo = false;
    if ( this.hasActiveHUD ) this.layer.hud.clear();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _destroy(options) {
    this._removeAllFilterEffects();
    this.stopAnimation();                       // Cancel movement animations
    canvas.primary.removeToken(this);           // Remove the TokenMesh from the PrimaryCanvasGroup
    this.#destroyLightSource();                 // Destroy the LightSource
    this.#destroyVisionSource();                // Destroy the VisionSource
    if ( this.#unlinkedVideo ) this.texture?.baseTexture?.destroy();  // Destroy base texture if the token has an unlinked video
    this.removeChildren().forEach(c => c.destroy({children: true}));
    this.texture = undefined;
    this.#unlinkedVideo = false;
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.#cleanData();

    // Load token texture
    let texture;
    if ( this._original ) texture = this._original.texture?.clone();
    else texture = await loadTexture(this.document.texture.src, {fallback: CONST.DEFAULT_TOKEN});

    // Cache token ring subject texture if needed
    const ring = this.document.ring;
    if ( ring.enabled && ring.subject.texture ) await loadTexture(ring.subject.texture);

    // Manage video playback
    let video = game.video.getVideoSource(texture);
    this.#unlinkedVideo = !!video && !this._original;
    if ( this.#unlinkedVideo ) {
      texture = await game.video.cloneTexture(video);
      video = game.video.getVideoSource(texture);
      const playOptions = {volume: 0};
      if ( (this.document.getFlag("core", "randomizeVideo") !== false) && Number.isFinite(video.duration) ) {
        playOptions.offset = Math.random() * video.duration;
      }
      game.video.play(video, playOptions);
    }
    this.texture = texture;

    // Draw the TokenMesh in the PrimaryCanvasGroup
    this.mesh = canvas.primary.addToken(this);

    // Initialize token ring
    this.#initializeRing();

    // Draw the border
    this.border ||= this.addChild(new PIXI.Graphics());

    // Draw the void of the TokenMesh
    if ( !this.voidMesh ) {
      this.voidMesh = this.addChild(new PIXI.Container());
      this.voidMesh.updateTransform = () => {};
      this.voidMesh.render = renderer => this.mesh?._renderVoid(renderer);
    }

    // Draw the detection filter of the TokenMesh
    if ( !this.detectionFilterMesh ) {
      this.detectionFilterMesh = this.addChild(new PIXI.Container());
      this.detectionFilterMesh.updateTransform = () => {};
      this.detectionFilterMesh.render = renderer => {
        if ( this.detectionFilter ) this._renderDetectionFilter(renderer);
      };
    }

    // Draw Token interface components
    this.bars ||= this.addChild(this.#drawAttributeBars());
    this.tooltip ||= this.addChild(this.#drawTooltip());
    this.effects ||= this.addChild(new PIXI.Container());

    this.target ||= this.addChild(new PIXI.Graphics());
    this.nameplate ||= this.addChild(this.#drawNameplate());

    // Add filter effects
    this._updateSpecialStatusFilterEffects();

    // Draw elements
    await this._drawEffects();

    // Initialize sources
    if ( !this.isPreview ) this.initializeSources();
  }

  /* -------------------------------------------- */

  /**
   * Create a point light source according to token options.
   * @returns {PointDarknessSource|PointLightSource}
   */
  #createLightSource() {
    const lightSourceClass = this.document.light.negative
      ? CONFIG.Canvas.darknessSourceClass : CONFIG.Canvas.lightSourceClass;
    return new lightSourceClass({sourceId: this.sourceId, object: this});
  }

  /* -------------------------------------------- */

  /**
   * Destroy the PointLightSource or PointDarknessSource instance associated with this Token.
   */
  #destroyLightSource() {
    this.light?.destroy();
    this.light = undefined;
  }

  /* -------------------------------------------- */

  /**
   * Create a point vision source for the Token.
   * @returns {PointVisionSource}
   */
  #createVisionSource() {
    return new CONFIG.Canvas.visionSourceClass({sourceId: this.sourceId, object: this});
  }

  /* -------------------------------------------- */

  /**
   * Destroy the PointVisionSource instance associated with this Token.
   */
  #destroyVisionSource() {
    this.vision?.visionMode?.deactivate(this.vision);
    this.vision?.destroy();
    this.vision = undefined;
  }

  /* -------------------------------------------- */

  /**
   * Apply initial sanitizations to the provided input data to ensure that a Token has valid required attributes.
   * Constrain the Token position to remain within the Canvas rectangle.
   */
  #cleanData() {
    const d = this.scene.dimensions;
    const {x: cx, y: cy} = this.getCenterPoint({x: 0, y: 0});
    this.document.x = Math.clamp(this.document.x, -cx, d.width - cx);
    this.document.y = Math.clamp(this.document.y, -cy, d.height - cy);
  }

  /* -------------------------------------------- */

  /**
   * Draw resource bars for the Token
   * @returns {PIXI.Container}
   */
  #drawAttributeBars() {
    const bars = new PIXI.Container();
    bars.bar1 = bars.addChild(new PIXI.Graphics());
    bars.bar2 = bars.addChild(new PIXI.Graphics());
    return bars;
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @override */
  _applyRenderFlags(flags) {
    if ( flags.refreshState ) this._refreshState();
    if ( flags.refreshVisibility ) this._refreshVisibility();
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshRotation ) this._refreshRotation();
    if ( flags.refreshSize ) this._refreshSize();
    if ( flags.refreshElevation ) this._refreshElevation();
    if ( flags.refreshMesh ) this._refreshMesh();
    if ( flags.refreshShader ) this._refreshShader();
    if ( flags.refreshShape ) this._refreshShape();
    if ( flags.refreshBorder ) this._refreshBorder();
    if ( flags.refreshBars ) this.drawBars();
    if ( flags.refreshNameplate ) this._refreshNameplate();
    if ( flags.refreshTarget ) this._refreshTarget();
    if ( flags.refreshTooltip ) this._refreshTooltip();
    if ( flags.recoverFromPreview ) this._recoverFromPreview();
    if ( flags.refreshRingVisuals ) this._refreshRingVisuals();
    if ( flags.redrawEffects ) this.drawEffects();
    if ( flags.refreshEffects ) this._refreshEffects();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the token ring visuals if necessary.
   * @protected
   */
  _refreshRingVisuals() {
    if ( this.hasDynamicRing ) this.ring.configureVisuals();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the visibility.
   * @protected
   */
  _refreshVisibility() {
    const wasVisible = this.visible;
    this.visible = this.isVisible;
    if ( this.visible !== wasVisible ) MouseInteractionManager.emulateMoveEvent();
    this.mesh.visible = this.visible && this.renderable;
    if ( this.layer.occlusionMode === CONST.TOKEN_OCCLUSION_MODES.VISIBLE ) {
      canvas.perception.update({refreshOcclusion: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Refresh aspects of the user interaction state.
   * For example the border, nameplate, or bars may be shown on Hover or on Control.
   * @protected
   */
  _refreshState() {
    this.alpha = this._getTargetAlpha();
    this.border.tint = this.#getBorderColor();
    const isSecret = this.document.isSecret;
    const isHover = this.hover || this.layer.highlightObjects;
    this.removeChild(this.voidMesh);
    this.addChildAt(this.voidMesh, this.getChildIndex(this.border) + (isHover ? 0 : 1));
    this.border.visible = !isSecret && (this.controlled || isHover);
    this.nameplate.visible = !isSecret && this._canViewMode(this.document.displayName);
    this.bars.visible = !isSecret && (this.actor && this._canViewMode(this.document.displayBars));
    this.tooltip.visible = !isSecret;
    this.effects.visible = !isSecret;
    this.target.visible = !isSecret;
    this.cursor = !isSecret ? "pointer" : null;
    this.zIndex = this.mesh.zIndex = this.controlled ? 2 : this.hover ? 1 : 0;
    this.mesh.sort = this.document.sort;
    this.mesh.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.TOKENS;
    this.mesh.alpha = this.alpha * this.document.alpha;
    this.mesh.hidden = this.document.hidden;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the size.
   * @protected
   */
  _refreshSize() {
    const {width, height} = this.getSize();
    const {fit, scaleX, scaleY} = this.document.texture;
    let adjustedScaleX = scaleX;
    let adjustedScaleY = scaleY;
    if ( this.hasDynamicRing && CONFIG.Token.ring.isGridFitMode ) {
      adjustedScaleX *= this.ring.subjectScaleAdjustment;
      adjustedScaleY *= this.ring.subjectScaleAdjustment;
    }
    this.mesh.resize(width, height, {fit, scaleX: adjustedScaleX, scaleY: adjustedScaleY});
    this.nameplate.position.set(width / 2, height + 2);
    this.tooltip.position.set(width / 2, -2);
    if ( this.hasDynamicRing ) this.ring.configureSize();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the shape.
   * @protected
   */
  _refreshShape() {
    this.shape = this.getShape();
    this.hitArea = this.shape;
    MouseInteractionManager.emulateMoveEvent();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the rotation.
   * @protected
   */
  _refreshRotation() {
    this.mesh.angle = this.document.lockRotation ? 0 : this.document.rotation;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the position.
   * @protected
   */
  _refreshPosition() {
    const {x, y} = this.document;
    if ( (this.position.x !== x) || (this.position.y !== y) ) MouseInteractionManager.emulateMoveEvent();
    this.position.set(x, y);
    this.mesh.position = this.center;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the elevation
   * @protected
   */
  _refreshElevation() {
    this.mesh.elevation = this.document.elevation;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the tooltip.
   * @protected
   */
  _refreshTooltip() {
    this.tooltip.text = this._getTooltipText();
    this.tooltip.style = this._getTextStyle();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the text content, position, and visibility of the Token nameplate.
   * @protected
   */
  _refreshNameplate() {
    this.nameplate.text = this.document.name;
    this.nameplate.style = this._getTextStyle();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the token mesh.
   * @protected
   */
  _refreshMesh() {
    const {alpha, texture: {anchorX, anchorY, fit, scaleX, scaleY, tint, alphaThreshold}} = this.document;
    const {width, height} = this.getSize();
    let adjustedScaleX = scaleX;
    let adjustedScaleY = scaleY;
    if ( this.hasDynamicRing && CONFIG.Token.ring.isGridFitMode ) {
      adjustedScaleX *= this.ring.subjectScaleAdjustment;
      adjustedScaleY *= this.ring.subjectScaleAdjustment;
    }
    this.mesh.resize(width, height, {fit, scaleX: adjustedScaleX, scaleY: adjustedScaleY});
    this.mesh.anchor.set(anchorX, anchorY);
    this.mesh.alpha = this.alpha * alpha;
    this.mesh.tint = tint;
    this.mesh.textureAlphaThreshold = alphaThreshold;
    this.mesh.occludedAlpha = 0.5;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the token mesh shader.
   * @protected
   */
  _refreshShader() {
    if ( this.hasDynamicRing ) this.mesh.setShaderClass(CONFIG.Token.ring.shaderClass);
    else this.mesh.setShaderClass(PrimaryBaseSamplerShader);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the border.
   * @protected
   */
  _refreshBorder() {
    const thickness = CONFIG.Canvas.objectBorderThickness;
    this.border.clear();
    this.border.lineStyle({width: thickness, color: 0x000000, alignment: 0.75, join: PIXI.LINE_JOIN.ROUND});
    this.border.drawShape(this.shape);
    this.border.lineStyle({width: thickness / 2, color: 0xFFFFFF, alignment: 1, join: PIXI.LINE_JOIN.ROUND});
    this.border.drawShape(this.shape);
  }

  /* -------------------------------------------- */

  /**
   * Get the hex color that should be used to render the Token border
   * @returns {number}    The hex color used to depict the border color
   * @protected
   */
  _getBorderColor() {
    const colors = CONFIG.Canvas.dispositionColors;
    if ( this.controlled || (this.isOwner && !game.user.isGM) ) return colors.CONTROLLED;
    const D = CONST.TOKEN_DISPOSITIONS;
    switch ( this.document.disposition ) {
      case D.SECRET: return colors.SECRET;
      case D.HOSTILE: return colors.HOSTILE;
      case D.NEUTRAL: return colors.NEUTRAL;
      case D.FRIENDLY: return this.actor?.hasPlayerOwner ? colors.PARTY : colors.FRIENDLY;
      default: throw new Error("Invalid disposition");
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the hex color that should be used to render the Token border
   * @returns {number}            The border color
   */
  #getBorderColor() {
    let color = this._getBorderColor();
    /** @deprecated since v12 */
    if ( typeof color !== "number" ) {
      color = CONFIG.Canvas.dispositionColors.INACTIVE;
      const msg = "Token#_getBorderColor returning null is deprecated.";
      foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    }
    return color;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} ReticuleOptions
   * @property {number} [margin=0]        The amount of margin between the targeting arrows and the token's bounding
   *                                      box, expressed as a fraction of an arrow's size.
   * @property {number} [alpha=1]         The alpha value of the arrows.
   * @property {number} [size=0.15]       The size of the arrows as a proportion of grid size.
   * @property {number} [color=0xFF6400]  The color of the arrows.
   * @property {object} [border]          The arrows' border style configuration.
   * @property {number} [border.color=0]  The border color.
   * @property {number} [border.width=2]  The border width.
   */

  /**
   * Refresh the target indicators for the Token.
   * Draw both target arrows for the primary User and indicator pips for other Users targeting the same Token.
   * @param {ReticuleOptions} [reticule]  Additional parameters to configure how the targeting reticule is drawn.
   * @protected
   */
  _refreshTarget(reticule) {
    this.target.clear();

    // We don't show the target arrows for a secret token disposition and non-GM users
    if ( !this.targeted.size ) return;

    // Determine whether the current user has target and any other users
    const [others, user] = Array.from(this.targeted).partition(u => u === game.user);

    // For the current user, draw the target arrows
    if ( user.length ) this._drawTarget(reticule);

    // For other users, draw offset pips
    const hw = (this.w / 2) + (others.length % 2 === 0 ? 8 : 0);
    for ( let [i, u] of others.entries() ) {
      const offset = Math.floor((i+1) / 2) * 16;
      const sign = i % 2 === 0 ? 1 : -1;
      const x = hw + (sign * offset);
      this.target.beginFill(u.color, 1.0).lineStyle(2, 0x0000000).drawCircle(x, 0, 6);
    }
  }

  /* -------------------------------------------- */

  /**
   * Draw the targeting arrows around this token.
   * @param {ReticuleOptions} [reticule]  Additional parameters to configure how the targeting reticule is drawn.
   * @protected
   */
  _drawTarget({margin: m=0, alpha=1, size=.15, color, border: {width=2, color: lineColor=0}={}}={}) {
    const l = canvas.dimensions.size * size; // Side length.
    const {h, w} = this;
    const lineStyle = {color: lineColor, alpha, width, cap: PIXI.LINE_CAP.ROUND, join: PIXI.LINE_JOIN.BEVEL};
    color ??= this.#getBorderColor();
    m *= l * -1;
    this.target.beginFill(color, alpha).lineStyle(lineStyle)
      .drawPolygon([-m, -m, -m-l, -m, -m, -m-l]) // Top left
      .drawPolygon([w+m, -m, w+m+l, -m, w+m, -m-l]) // Top right
      .drawPolygon([-m, h+m, -m-l, h+m, -m, h+m+l]) // Bottom left
      .drawPolygon([w+m, h+m, w+m+l, h+m, w+m, h+m+l]); // Bottom right
  }

  /* -------------------------------------------- */

  /**
   * Refresh the display of Token attribute bars, rendering its latest resource data.
   * If the bar attribute is valid (has a value and max), draw the bar. Otherwise hide it.
   */
  drawBars() {
    if ( !this.actor || (this.document.displayBars === CONST.TOKEN_DISPLAY_MODES.NONE) ) return;
    ["bar1", "bar2"].forEach((b, i) => {
      const bar = this.bars[b];
      const attr = this.document.getBarAttribute(b);
      if ( !attr || (attr.type !== "bar") || (attr.max === 0) ) return bar.visible = false;
      this._drawBar(i, bar, attr);
      bar.visible = true;
    });
  }

  /* -------------------------------------------- */

  /**
   * Draw a single resource bar, given provided data
   * @param {number} number       The Bar number
   * @param {PIXI.Graphics} bar   The Bar container
   * @param {Object} data         Resource data for this bar
   * @protected
   */
  _drawBar(number, bar, data) {
    const val = Number(data.value);
    const pct = Math.clamp(val, 0, data.max) / data.max;

    // Determine sizing
    const {width, height} = this.getSize();
    const bw = width;
    const bh = Math.max(canvas.dimensions.size / 12, 8) * (this.document.height >= 2 ? 1.6 : 1);
    const bs = Math.clamp(bh / 8, 1, 2);

    // Determine the color to use
    let color;
    if ( number === 0 ) color = Color.fromRGB([1 - (pct / 2), pct, 0]);
    else color = Color.fromRGB([0.5 * pct, 0.7 * pct, 0.5 + (pct / 2)]);

    // Draw the bar
    bar.clear();
    bar.lineStyle(bs, 0x000000, 1.0);
    bar.beginFill(0x000000, 0.5).drawRoundedRect(0, 0, bw, bh, 3);
    bar.beginFill(color, 1.0).drawRoundedRect(0, 0, pct * bw, bh, 2);

    // Set position
    const posY = number === 0 ? height - bh : 0;
    bar.position.set(0, posY);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Draw the token's nameplate as a text object
   * @returns {PreciseText}    The Text object for the Token nameplate
   */
  #drawNameplate() {
    const nameplate = new PreciseText(this.document.name, this._getTextStyle());
    nameplate.anchor.set(0.5, 0);
    return nameplate;
  }

  /* -------------------------------------------- */

  /**
   * Draw a text tooltip for the token which can be used to display Elevation or a resource value
   * @returns {PreciseText}     The text object used to render the tooltip
   */
  #drawTooltip() {
    const tooltip = new PreciseText(this._getTooltipText(), this._getTextStyle());
    tooltip.anchor.set(0.5, 1);
    return tooltip;
  }

  /* -------------------------------------------- */

  /**
   * Return the text which should be displayed in a token's tooltip field
   * @returns {string}
   * @protected
   */
  _getTooltipText() {
    let elevation = this.document.elevation;
    if ( !Number.isFinite(elevation) || (elevation === 0) ) return "";
    let text = String(elevation);
    if ( elevation > 0 ) text = `+${text}`;
    const units = canvas.grid.units;
    if ( units ) text = `${text} ${units}`;
    return text;
  }

  /* -------------------------------------------- */

  /**
   * Get the text style that should be used for this Token's tooltip.
   * @returns {string}
   * @protected
   */
  _getTextStyle() {
    const style = CONFIG.canvasTextStyle.clone();
    style.fontSize = 24;
    if (canvas.dimensions.size >= 200) style.fontSize = 28;
    else if (canvas.dimensions.size < 50) style.fontSize = 20;
    style.wordWrapWidth = this.w * 2.5;
    return style;
  }

  /* -------------------------------------------- */

  /**
   * Draw the effect icons for ActiveEffect documents which apply to the Token's Actor.
   */
  async drawEffects() {
    return this._partialDraw(() => this._drawEffects());
  }

  /* -------------------------------------------- */

  /**
   * Draw the effect icons for ActiveEffect documents which apply to the Token's Actor.
   * Called by {@link Token#drawEffects}.
   * @protected
   */
  async _drawEffects() {
    this.effects.renderable = false;

    // Clear Effects Container
    this.effects.removeChildren().forEach(c => c.destroy());
    this.effects.bg = this.effects.addChild(new PIXI.Graphics());
    this.effects.bg.zIndex = -1;
    this.effects.overlay = null;

    // Categorize effects
    const activeEffects = this.actor?.temporaryEffects || [];
    const overlayEffect = activeEffects.findLast(e => e.img && e.getFlag("core", "overlay"));

    // Draw effects
    const promises = [];
    for ( const [i, effect] of activeEffects.entries() ) {
      if ( !effect.img ) continue;
      const promise = effect === overlayEffect
        ? this._drawOverlay(effect.img, effect.tint)
        : this._drawEffect(effect.img, effect.tint);
      promises.push(promise.then(e => {
        if ( e ) e.zIndex = i;
      }));
    }
    await Promise.allSettled(promises);

    this.effects.sortChildren();
    this.effects.renderable = true;
    this.renderFlags.set({refreshEffects: true});
  }

  /* -------------------------------------------- */

  /**
   * Draw a status effect icon
   * @param {string} src
   * @param {PIXI.ColorSource|null} tint
   * @returns {Promise<PIXI.Sprite|undefined>}
   * @protected
   */
  async _drawEffect(src, tint) {
    if ( !src ) return;
    const tex = await loadTexture(src, {fallback: "icons/svg/hazard.svg"});
    const icon = new PIXI.Sprite(tex);
    icon.tint = tint ?? 0xFFFFFF;
    return this.effects.addChild(icon);
  }

  /* -------------------------------------------- */

  /**
   * Draw the overlay effect icon
   * @param {string} src
   * @param {number|null} tint
   * @returns {Promise<PIXI.Sprite>}
   * @protected
   */
  async _drawOverlay(src, tint) {
    const icon = await this._drawEffect(src, tint);
    if ( icon ) icon.alpha = 0.8;
    this.effects.overlay = icon ?? null;
    return icon;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the display of status effects, adjusting their position for the token width and height.
   * @protected
   */
  _refreshEffects() {
    let i = 0;
    const size = Math.round(canvas.dimensions.size / 10) * 2;
    const rows = Math.floor(this.document.height * 5);
    const bg = this.effects.bg.clear().beginFill(0x000000, 0.40).lineStyle(1.0, 0x000000);
    for ( const effect of this.effects.children ) {
      if ( effect === bg ) continue;

      // Overlay effect
      if ( effect === this.effects.overlay ) {
        const {width, height} = this.getSize();
        const size = Math.min(width * 0.6, height * 0.6);
        effect.width = effect.height = size;
        effect.position = this.getCenterPoint({x: 0, y: 0});
        effect.anchor.set(0.5, 0.5);
      }

      // Status effect
      else {
        effect.width = effect.height = size;
        effect.x = Math.floor(i / rows) * size;
        effect.y = (i % rows) * size;
        bg.drawRoundedRect(effect.x + 1, effect.y + 1, size - 2, size - 2, 2);
        i++;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Helper method to determine whether a token attribute is viewable under a certain mode
   * @param {number} mode   The mode from CONST.TOKEN_DISPLAY_MODES
   * @returns {boolean}      Is the attribute viewable?
   * @protected
   */
  _canViewMode(mode) {
    if ( mode === CONST.TOKEN_DISPLAY_MODES.NONE ) return false;
    else if ( mode === CONST.TOKEN_DISPLAY_MODES.ALWAYS ) return true;
    else if ( mode === CONST.TOKEN_DISPLAY_MODES.CONTROL ) return this.controlled;
    else if ( mode === CONST.TOKEN_DISPLAY_MODES.HOVER ) return this.hover || this.layer.highlightObjects;
    else if ( mode === CONST.TOKEN_DISPLAY_MODES.OWNER_HOVER ) return this.isOwner
      && (this.hover || this.layer.highlightObjects);
    else if ( mode === CONST.TOKEN_DISPLAY_MODES.OWNER ) return this.isOwner;
    return false;
  }

  /* -------------------------------------------- */
  /*  Token Ring                                  */
  /* -------------------------------------------- */

  /**
   * Override ring colors for this particular Token instance.
   * @returns {{[ring]: Color, [background]: Color}}
   */
  getRingColors() {
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Apply additional ring effects for this particular Token instance.
   * Effects are returned as an array of integers in {@link foundry.canvas.tokens.TokenRing.effects}.
   * @returns {number[]}
   */
  getRingEffects() {
    return [];
  }

  /* -------------------------------------------- */
  /*  Token Animation                             */
  /* -------------------------------------------- */

  /**
   * Get the animation data for the current state of the document.
   * @returns {TokenAnimationData}         The target animation data object
   * @protected
   */
  _getAnimationData() {
    const doc = this.document;
    const {x, y, width, height, rotation, alpha} = doc;
    const {src, anchorX, anchorY, scaleX, scaleY, tint} = doc.texture;
    const texture = {src, anchorX, anchorY, scaleX, scaleY, tint};
    const subject = {
      texture: doc.ring.subject.texture,
      scale: doc.ring.subject.scale
    };
    return {x, y, width, height, rotation, alpha, texture, ring: {subject}};
  }

  /* -------------------------------------------- */

  /**
   * Animate from the old to the new state of this Token.
   * @param {Partial<TokenAnimationData>} to      The animation data to animate to
   * @param {object} [options]                    The options that configure the animation behavior.
   *                                              Passed to {@link Token#_getAnimationDuration}.
   * @param {number} [options.duration]           The duration of the animation in milliseconds
   * @param {number} [options.movementSpeed=6]    A desired token movement speed in grid spaces per second
   * @param {string} [options.transition]         The desired texture transition type
   * @param {Function|string} [options.easing]    The easing function of the animation
   * @param {string|symbol|null} [options.name]   The name of the animation, or null if nameless.
   *                                              The default is {@link Token#animationName}.
   * @param {Function} [options.ontick]           A on-tick callback
   * @returns {Promise<void>}                     A promise which resolves once the animation has finished or stopped
   */
  async animate(to, {duration, easing, movementSpeed, name, ontick, ...options}={}) {
    /** @deprecated since v12 */
    if ( "a0" in options ) {
      const msg = "Passing a0 to Token#animate is deprecated without replacement.";
      foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    }

    // Get the name and the from and to animation data
    if ( name === undefined ) name = this.animationName;
    else name ||= Symbol(this.animationName);
    const from = this.#animationData;
    to = foundry.utils.filterObject(to, this.#animationData);
    let context = this.#animationContexts.get(name);
    if ( context ) to = foundry.utils.mergeObject(context.to, to, {inplace: false});

    // Conclude the current animation
    CanvasAnimation.terminateAnimation(name);
    if ( context ) this.#animationContexts.delete(name);

    // Get the animation duration and create the animation context
    duration ??= this._getAnimationDuration(from, to, {movementSpeed, ...options});
    context = {name, to, duration, time: 0, preAnimate: [], postAnimate: [], onAnimate: []};

    // Animate the first frame
    this.#animateFrame(context);

    // If the duration of animation is not positive, we can immediately conclude the animation
    if ( duration <= 0 ) return;

    // Set the animation context
    this.#animationContexts.set(name, context);

    // Prepare the animation data changes
    const changes = foundry.utils.diffObject(from, to);
    const attributes = this._prepareAnimation(from, changes, context, options);

    // Dispatch the animation
    context.promise = CanvasAnimation.animate(attributes, {
      name,
      context: this,
      duration,
      easing,
      priority: PIXI.UPDATE_PRIORITY.OBJECTS + 1, // Before perception updates and Token render flags
      wait: Promise.allSettled(context.preAnimate.map(fn => fn(context))),
      ontick: (dt, anim) => {
        context.time = anim.time;
        if ( ontick ) ontick(dt, anim, this.#animationData);
        this.#animateFrame(context);
      }
    });
    await context.promise.finally(() => {
      if ( this.#animationContexts.get(name) === context ) this.#animationContexts.delete(name);
      for ( const fn of context.postAnimate ) fn(context);
    });
  }

  /* -------------------------------------------- */

  /**
   * Get the duration of the animation.
   * @param {TokenAnimationData} from             The animation data to animate from
   * @param {Partial<TokenAnimationData>} to      The animation data to animate to
   * @param {object} [options]                    The options that configure the animation behavior
   * @param {number} [options.movementSpeed=6]    A desired token movement speed in grid spaces per second
   * @returns {number}                            The duration of the animation in milliseconds
   * @protected
   */
  _getAnimationDuration(from, to, {movementSpeed=6}={}) {
    let duration = 0;
    const dx = from.x - (to.x ?? from.x);
    const dy = from.y - (to.y ?? from.y);
    if ( dx || dy ) duration = Math.max(duration, Math.hypot(dx, dy) / canvas.dimensions.size / movementSpeed * 1000);
    const dr = ((Math.abs(from.rotation - (to.rotation ?? from.rotation)) + 180) % 360) - 180;
    if ( dr ) duration = Math.max(duration, Math.abs(dr) / (movementSpeed * 60) * 1000);
    if ( !duration ) duration = 1000; // The default animation duration is 1 second
    return duration;
  }

  /* -------------------------------------------- */

  /**
   * Handle a single frame of a token animation.
   * @param {TokenAnimationContext} context    The animation context
   */
  #animateFrame(context) {
    if ( context.time >= context.duration ) foundry.utils.mergeObject(this.#animationData, context.to);
    const changes = foundry.utils.diffObject(this.#priorAnimationData, this.#animationData);
    foundry.utils.mergeObject(this.#priorAnimationData, this.#animationData);
    foundry.utils.mergeObject(this.document, this.#animationData, {insertKeys: false});
    for ( const fn of context.onAnimate ) fn(context);
    this._onAnimationUpdate(changes, context);
  }

  /* -------------------------------------------- */

  /**
   * Called each animation frame.
   * @param {Partial<TokenAnimationData>} changed    The animation data that changed
   * @param {TokenAnimationContext} context          The animation context
   * @protected
   */
  _onAnimationUpdate(changed, context) {
    const positionChanged = ("x" in changed) || ("y" in changed);
    const rotationChanged = ("rotation" in changed);
    const sizeChanged = ("width" in changed) || ("height" in changed);
    const textureChanged = "texture" in changed;
    const ringEnabled = this.document.ring.enabled;
    const ringChanged = "ring" in changed;
    const ringSubjectChanged = ringEnabled && ringChanged && ("subject" in changed.ring);
    const ringSubjectTextureChanged = ringSubjectChanged && ("texture" in changed.ring.subject);
    const ringSubjectScaleChanged = ringSubjectChanged && ("scale" in changed.ring.subject);
    this.renderFlags.set({
      redraw: (textureChanged && ("src" in changed.texture)) || ringSubjectTextureChanged,
      refreshVisibility: positionChanged || sizeChanged,
      refreshPosition: positionChanged,
      refreshRotation: rotationChanged && !this.document.lockRotation,
      refreshSize: sizeChanged || ringSubjectScaleChanged,
      refreshMesh: textureChanged || ("alpha" in changed)
    });

    // Update occlusion and/or sounds and the HUD if necessary
    if ( positionChanged || sizeChanged ) {
      canvas.perception.update({refreshSounds: true, refreshOcclusionMask: true, refreshOcclusionStates: true});
      if ( this.hasActiveHUD ) this.layer.hud.clear();
    }

    // Update light and sight sources unless Vision Animation is disabled
    if ( (context.time < context.duration) && !game.settings.get("core", "visionAnimation") ) return;
    const perspectiveChanged = positionChanged || sizeChanged || (rotationChanged && this.hasLimitedSourceAngle);
    const visionChanged = perspectiveChanged && this.hasSight;
    const lightChanged = perspectiveChanged && this._isLightSource();
    if ( visionChanged || lightChanged ) this.initializeSources();
  }

  /* -------------------------------------------- */

  /**
   * Terminate the animations of this particular Token, if exists.
   * @param {object} [options]                Additional options.
   * @param {boolean} [options.reset=true]    Reset the TokenDocument?
   */
  stopAnimation({reset=true}={}) {
    if ( reset ) this.document.reset();
    for ( const name of this.#animationContexts.keys() ) CanvasAnimation.terminateAnimation(name);
    this.#animationContexts.clear();
    const to = this._getAnimationData();
    const changes = foundry.utils.diffObject(this.#animationData, to);
    foundry.utils.mergeObject(this.#animationData, to);
    foundry.utils.mergeObject(this.#priorAnimationData, this.#animationData);
    if ( foundry.utils.isEmpty(changes) ) return;
    const context = {name: Symbol(this.animationName), to, duration: 0, time: 0,
      preAnimate: [], postAnimate: [], onAnimate: []};
    this._onAnimationUpdate(changes, context);
  }

  /* -------------------------------------------- */
  /*  Animation Preparation Methods               */
  /* -------------------------------------------- */

  /**
   * Move the token immediately to the destination if it is teleported.
   * @param {Partial<TokenAnimationData>} to    The animation data to animate to
   */
  #handleTeleportAnimation(to) {
    const changes = {};
    if ( "x" in to ) this.#animationData.x = changes.x = to.x;
    if ( "y" in to ) this.#animationData.y = changes.y = to.y;
    if ( "elevation" in to ) this.#animationData.elevation = changes.elevation = to.elevation;
    if ( !foundry.utils.isEmpty(changes) ) {
      const context = {name: Symbol(this.animationName), to: changes, duration: 0, time: 0,
        preAnimate: [], postAnimate: [], onAnimate: []};
      this._onAnimationUpdate(changes, context);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle the rotation changes for the animation, ensuring the shortest rotation path.
   * @param {TokenAnimationData} from      The animation data to animate from
   * @param {Partial<TokenAnimationData>} changes  The animation data changes
   */
  static #handleRotationChanges(from, changes) {
    if ( "rotation" in changes ) {
      let dr = changes.rotation - from.rotation;
      while ( dr > 180 ) dr -= 360;
      while ( dr < -180 ) dr += 360;
      changes.rotation = from.rotation + dr;
    }
  }

  /* -------------------------------------------- */

  /**
   * Update the padding for both the source and target tokens to ensure they are square.
   * @param {PrimarySpriteMesh} sourceMesh  The source mesh
   * @param {PrimarySpriteMesh} targetMesh  The target mesh
   */
  static #updatePadding(sourceMesh, targetMesh) {
    const calculatePadding = ({width, height}) => ({
      x: width > height ? 0 : (height - width) / 2,
      y: height > width ? 0 : (width - height) / 2
    });

    const paddingSource = calculatePadding(sourceMesh.texture);
    sourceMesh.paddingX = paddingSource.x;
    sourceMesh.paddingY = paddingSource.y;

    const paddingTarget = calculatePadding(targetMesh.texture);
    targetMesh.paddingX = paddingTarget.x;
    targetMesh.paddingY = paddingTarget.y;
  }

  /* -------------------------------------------- */

  /**
   * Create a texture transition filter with the given options.
   * @param {object} options  The options that configure the filter
   * @returns {TextureTransitionFilter}  The created filter
   */
  static #createTransitionFilter(options) {
    const filter = TextureTransitionFilter.create();
    filter.enabled = false;
    filter.type = options.transition ?? "fade";
    return filter;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the animation data changes: performs special handling required for animating rotation.
   * @param {TokenAnimationData} from                         The animation data to animate from
   * @param {Partial<TokenAnimationData>} changes             The animation data changes
   * @param {Omit<TokenAnimationContext, "promise">} context  The animation context
   * @param {object} [options]                                The options that configure the animation behavior
   * @param {string} [options.transition="fade"]              The desired texture transition type
   * @returns {CanvasAnimationAttribute[]}                    The animation attributes
   * @protected
   */
  _prepareAnimation(from, changes, context, options = {}) {
    const attributes = [];

    Token.#handleRotationChanges(from, changes);
    this.#handleTransitionChanges(changes, context, options, attributes);

    // Create animation attributes from the changes
    const recur = (changes, parent) => {
      for ( const [attribute, to] of Object.entries(changes) ) {
        const type = foundry.utils.getType(to);
        if ( type === "Object" ) recur(to, parent[attribute]);
        else if ( type === "number" || type === "Color" ) attributes.push({attribute, parent, to});
      }
    };
    recur(changes, this.#animationData);
    return attributes;
  }

  /* -------------------------------------------- */

  /**
   * Handle the transition changes, creating the necessary filter and preparing the textures.
   * @param {Partial<TokenAnimationData>} changed       The animation data that changed
   * @param {Omit<TokenAnimationContext, "promise">} context  The animation context
   * @param {object} options                            The options that configure the animation behavior
   * @param {CanvasAnimationAttribute[]} attributes     The array to push animation attributes to
   */
  #handleTransitionChanges(changed, context, options, attributes) {
    const textureChanged = ("texture" in changed) && ("src" in changed.texture);
    const ringEnabled = this.document.ring.enabled;
    const subjectTextureChanged = ringEnabled && ("ring" in changed) && ("subject" in changed.ring) && ("texture" in changed.ring.subject);

    // If no texture has changed, no need for a transition
    if ( !(textureChanged || subjectTextureChanged) ) return;

    const filter = Token.#createTransitionFilter(options);
    let renderTexture;
    let targetMesh;
    let targetToken;

    if ( this.mesh ) {
      this.mesh.filters ??= [];
      this.mesh.filters.unshift(filter);
    }

    context.preAnimate.push(async function() {
      const targetAsset = !ringEnabled ? changed.texture.src
        : (subjectTextureChanged ? changed.ring.subject.texture : this.document.ring.subject.texture);
      const targetTexture = await loadTexture(targetAsset, {fallback: CONST.DEFAULT_TOKEN});
      targetToken = this.#prepareTargetToken(targetTexture);

      // Create target primary sprite mesh and assign to the target token
      targetMesh = new PrimarySpriteMesh({object: targetToken});
      targetMesh.texture = targetTexture;
      targetToken.mesh = targetMesh;

      // Prepare source and target meshes and shader class
      if ( ringEnabled ) {
        targetToken.#ring = new CONFIG.Token.ring.ringClass(targetToken);
        targetToken.#ring.configure(targetMesh);
        targetMesh.setShaderClass(CONFIG.Token.ring.shaderClass);
      }
      else {
        Token.#updatePadding(this.mesh, targetMesh);
        targetMesh.setShaderClass(PrimaryBaseSamplerShader);
      }

      // Prepare mesh position for rendering
      targetMesh.position.set(targetMesh.paddingX, targetMesh.paddingY);

      // Configure render texture and render the target mesh into it
      const renderer = canvas.app.renderer;
      renderTexture = renderer.generateTexture(targetMesh, {resolution: targetMesh.texture.resolution});

      // Add animation function if ring effects are enabled
      if ( targetToken.hasDynamicRing && (this.document.ring.effects > CONFIG.Token.ring.ringClass.effects.ENABLED) ) {
        context.onAnimate.push(function() {
          canvas.app.renderer.render(targetMesh, {renderTexture});
        });
      }

      // Preparing the transition filter
      filter.targetTexture = renderTexture;
      filter.enabled = true;
    }.bind(this));

    context.postAnimate.push(function() {
      targetMesh?.destroy();
      renderTexture?.destroy(true);
      targetToken?.destroy({children: true});
      this.mesh?.filters?.findSplice(f => f === filter);
      if ( !this.hasDynamicRing && this.mesh ) this.mesh.padding = 0;
    }.bind(this));

    attributes.push({attribute: "progress", parent: filter.uniforms, to: 1});
  }

  /* -------------------------------------------- */

  /**
   * Prepare a target token by cloning the current token and setting its texture.
   * @param {PIXI.Texture} targetTexture  The texture to set on the target token
   * @returns {Token}  The prepared target token
   * @internal
   */
  #prepareTargetToken(targetTexture) {
    const cloneDoc = this.document.clone();
    const clone = cloneDoc.object;
    clone.texture = targetTexture;
    return clone;
  }

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /**
   * Check for collision when attempting a move to a new position
   * @param {Point} destination           The central destination point of the attempted movement
   * @param {object} [options={}]         Additional options forwarded to PointSourcePolygon.testCollision
   * @param {Point} [options.origin]      The origin to be used instead of the current origin
   * @param {PointSourcePolygonType} [options.type="move"]    The collision type
   * @param {"any"|"all"|"closest"} [options.mode="any"]      The collision mode to test: "any", "all", or "closest"
   * @returns {boolean|PolygonVertex|PolygonVertex[]|null}    The collision result depends on the mode of the test:
   *                                                * any: returns a boolean for whether any collision occurred
   *                                                * all: returns a sorted array of PolygonVertex instances
   *                                                * closest: returns a PolygonVertex instance or null
   */
  checkCollision(destination, {origin, type="move", mode="any"}={}) {

    // Round origin and destination such that the top-left point (i.e. the Token's position) is integer
    const {x: cx, y: cy} = this.getCenterPoint({x: 0, y: 0});
    if ( origin ) origin = {x: Math.round(origin.x - cx) + cx, y: Math.round(origin.y - cy) + cy};
    destination = {x: Math.round(destination.x - cx) + cx, y: Math.round(destination.y - cy) + cy};

    // The test origin is the last confirmed valid position of the Token
    const center = origin || this.getCenterPoint(this.#validPosition);
    origin = this.getMovementAdjustedPoint(center);

    // The test destination is the adjusted point based on the proposed movement vector
    const dx = destination.x - center.x;
    const dy = destination.y - center.y;
    const offsetX = dx === 0 ? this.#priorMovement.ox : Math.sign(dx);
    const offsetY = dy === 0 ? this.#priorMovement.oy : Math.sign(dy);
    destination = this.getMovementAdjustedPoint(destination, {offsetX, offsetY});

    // Reference the correct source object
    let source;
    switch ( type ) {
      case "move":
        source = this.#getMovementSource(origin); break;
      case "sight":
        source = this.vision; break;
      case "light":
        source = this.light; break;
      case "sound":
        throw new Error("Collision testing for Token sound sources is not supported at this time");
    }

    // Create a movement source passed to the polygon backend
    return CONFIG.Canvas.polygonBackends[type].testCollision(origin, destination, {type, mode, source});
  }

  /* -------------------------------------------- */

  /**
   * Prepare a PointMovementSource for the document
   * @param {Point} origin    The origin of the source
   * @returns {foundry.canvas.sources.PointMovementSource}
   */
  #getMovementSource(origin) {
    const movement = new foundry.canvas.sources.PointMovementSource({object: this});
    movement.initialize({x: origin.x, y: origin.y, elevation: this.document.elevation});
    return movement;
  }

  /* -------------------------------------------- */

  /**
   * Get the width and height of the Token in pixels.
   * @returns {{width: number, height: number}}    The size in pixels
   */
  getSize() {
    let {width, height} = this.document;
    const grid = this.scene.grid;
    if ( grid.isHexagonal ) {
      if ( grid.columns ) width = (0.75 * Math.floor(width)) + (0.5 * (width % 1)) + 0.25;
      else height = (0.75 * Math.floor(height)) + (0.5 * (height % 1)) + 0.25;
    }
    width *= grid.sizeX;
    height *= grid.sizeY;
    return {width, height};
  }

  /* -------------------------------------------- */

  /**
   * Get the shape of this Token.
   * @returns {PIXI.Rectangle|PIXI.Polygon|PIXI.Circle}
   */
  getShape() {
    const {width, height, hexagonalShape} = this.document;
    const grid = this.scene.grid;

    // Hexagonal shape
    if ( grid.isHexagonal ) {
      const shape = Token.#getHexagonalShape(grid.columns, hexagonalShape, width, height);
      if ( shape ) {
        const points = [];
        for ( let i = 0; i < shape.points.length; i += 2 ) {
          points.push(shape.points[i] * grid.sizeX, shape.points[i + 1] * grid.sizeY);
        }
        return new PIXI.Polygon(points);
      }

      // No hexagonal shape for this combination of shape type, width, and height.
      // Fallback to rectangular shape.
    }

    // Rectangular shape
    const size = this.getSize();
    return new PIXI.Rectangle(0, 0, size.width, size.height);
  }

  /* -------------------------------------------- */

  /**
   * Get the center point for a given position or the current position.
   * @param {Point} [position]    The position to be used instead of the current position
   * @returns {Point}             The center point
   */
  getCenterPoint(position) {
    const {x, y} = position ?? this.document;
    const {width, height, hexagonalShape} = this.document;
    const grid = this.scene.grid;

    // Hexagonal shape
    if ( grid.isHexagonal ) {
      const shape = Token.#getHexagonalShape(grid.columns, hexagonalShape, width, height);
      if ( shape ) {
        const center = shape.center;
        return {x: x + (center.x * grid.sizeX), y: y + (center.y * grid.sizeY)};
      }

      // No hexagonal shape for this combination of shape type, width, and height.
      // Fallback to the center of the rectangle.
    }

    // Rectangular shape
    const size = this.getSize();
    return {x: x + (size.width / 2), y: y + (size.height / 2)};
  }

  /* -------------------------------------------- */

  /** @override */
  getSnappedPosition(position) {
    position ??= this.document;
    const grid = this.scene.grid;
    if ( grid.isSquare ) return this.#snapToSquareGrid(position);
    if ( grid.isHexagonal ) return this.#snapToHexagonalGrid(position);
    return {x: position.x, y: position.y};
  }

  /* -------------------------------------------- */

  /**
   * Get the snapped position for a given position on a square grid.
   * @param {Point} position    The position that is snapped
   * @returns {Point}           The snapped position
   */
  #snapToSquareGrid(position) {
    const {width, height} = this.document;
    const grid = this.scene.grid;
    const M = CONST.GRID_SNAPPING_MODES;

    // Small tokens snap to any vertex of the subgrid with resolution 4
    // where the token is fully contained within the grid space
    if ( ((width === 0.5) && (height <= 1)) || ((width <= 1) && (height === 0.5)) ) {
      let x = position.x / grid.size;
      let y = position.y / grid.size;
      if ( width === 1 ) x = Math.round(x);
      else {
        x = Math.floor(x * 8);
        const k = ((x % 8) + 8) % 8;
        if ( k >= 6 ) x = Math.ceil(x / 8);
        else if ( k === 5 ) x = Math.floor(x / 8) + 0.5;
        else x = Math.round(x / 2) / 4;
      }
      if ( height === 1 ) y = Math.round(y);
      else {
        y = Math.floor(y * 8);
        const k = ((y % 8) + 8) % 8;
        if ( k >= 6 ) y = Math.ceil(y / 8);
        else if ( k === 5 ) y = Math.floor(y / 8) + 0.5;
        else y = Math.round(y / 2) / 4;
      }
      x *= grid.size;
      y *= grid.size;
      return {x, y};
    }

    const modeX = Number.isInteger(width) ? M.VERTEX : M.VERTEX | M.EDGE_MIDPOINT | M.CENTER;
    const modeY = Number.isInteger(height) ? M.VERTEX : M.VERTEX | M.EDGE_MIDPOINT | M.CENTER;
    if ( modeX === modeY ) return grid.getSnappedPoint(position, {mode: modeX});
    return {
      x: grid.getSnappedPoint(position, {mode: modeX}).x,
      y: grid.getSnappedPoint(position, {mode: modeY}).y
    };
  }

  /* -------------------------------------------- */

  /**
   * Get the snapped position for a given position on a hexagonal grid.
   * @param {Point} position    The position that is snapped
   * @returns {Point}           The snapped position
   */
  #snapToHexagonalGrid(position) {
    const {width, height, hexagonalShape} = this.document;
    const grid = this.scene.grid;
    const M = CONST.GRID_SNAPPING_MODES;

    // Hexagonal shape
    const shape = Token.#getHexagonalShape(grid.columns, hexagonalShape, width, height);
    if ( shape ) {
      const {behavior, anchor} = shape.snapping;
      const offsetX = anchor.x * grid.sizeX;
      const offsetY = anchor.y * grid.sizeY;
      position = grid.getSnappedPoint({x: position.x + offsetX, y: position.y + offsetY}, behavior);
      position.x -= offsetX;
      position.y -= offsetY;
      return position;
    }

    // Rectagular shape
    return grid.getSnappedPoint(position, {mode: M.CENTER | M.VERTEX | M.CORNER | M.SIDE_MIDPOINT});
  }

  /* -------------------------------------------- */

  /**
   * Test whether the Token is inside the Region.
   * This function determines the state of {@link TokenDocument#regions} and {@link RegionDocument#tokens}.
   *
   * Implementations of this function are restricted in the following ways:
   *   - If the bounds (given by {@link Token#getSize}) of the Token do not intersect the Region, then the Token is not
   *     contained within the Region.
   *   - If the Token is inside the Region a particular elevation, then the Token is inside the Region at any elevation
   *     within the elevation range of the Region.
   *
   * If this function is overridden, then {@link Token#segmentizeRegionMovement} must be overridden too.
   * @param {Region} region    The region.
   * @param {Point | (Point & {elevation: number}) | {elevation: number}} position
   *   The (x, y) and/or elevation to use instead of the current values.
   * @returns {boolean}        Is the Token inside the Region?
   */
  testInsideRegion(region, position) {
    return region.testPoint(this.getCenterPoint(position), position?.elevation ?? this.document.elevation);
  }

  /* -------------------------------------------- */

  /**
   * Split the Token movement through the waypoints into its segments.
   *
   * Implementations of this function are restricted in the following ways:
   *   - The segments must go through the waypoints.
   *   - The *from* position matches the *to* position of the succeeding segment.
   *   - The Token must be contained (w.r.t. {@link Token#testInsideRegion}) within the Region
   *     at the *from* and *to* of MOVE segments.
   *   - The Token must be contained (w.r.t. {@link Token#testInsideRegion}) within the Region
   *     at the *to* position of ENTER segments.
   *   - The Token must be contained (w.r.t. {@link Token#testInsideRegion}) within the Region
   *     at the *from* position of EXIT segments.
   *   - The Token must not be contained (w.r.t. {@link Token#testInsideRegion}) within the Region
   *     at the *from* position of ENTER segments.
   *   - The Token must not be contained (w.r.t. {@link Token#testInsideRegion}) within the Region
   *     at the *to* position of EXIT segments.
   * @param {Region} region                         The region.
   * @param {RegionMovementWaypoint[]} waypoints    The waypoints of movement.
   * @param {object} [options]                      Additional options
   * @param {boolean} [options.teleport=false]      Is it teleportation?
   * @returns {RegionMovementSegment[]}             The movement split into its segments.
   */
  segmentizeRegionMovement(region, waypoints, {teleport=false}={}) {
    return region.segmentizeMovement(waypoints, [this.getCenterPoint({x: 0, y: 0})], {teleport});
  }

  /* -------------------------------------------- */

  /**
   * Set this Token as an active target for the current game User.
   * Note: If the context is set with groupSelection:true, you need to manually broadcast the activity for other users.
   * @param {boolean} targeted                        Is the Token now targeted?
   * @param {object} [context={}]                     Additional context options
   * @param {User|null} [context.user=null]           Assign the token as a target for a specific User
   * @param {boolean} [context.releaseOthers=true]    Release other active targets for the same player?
   * @param {boolean} [context.groupSelection=false]  Is this target being set as part of a group selection workflow?
   */
  setTarget(targeted=true, {user=null, releaseOthers=true, groupSelection=false}={}) {

    // Do not allow setting a preview token as a target
    if ( this.isPreview ) return;

    // Release other targets
    user = user || game.user;
    if ( user.targets.size && releaseOthers ) {
      user.targets.forEach(t => {
        if ( t !== this ) t.setTarget(false, {user, releaseOthers: false, groupSelection: true});
      });
    }

    // Acquire target
    const wasTargeted = this.targeted.has(user);
    if ( targeted ) {
      this.targeted.add(user);
      user.targets.add(this);
    }

    // Release target
    else {
      this.targeted.delete(user);
      user.targets.delete(this);
    }

    // If target status changed
    if ( wasTargeted !== targeted ) {
      this.renderFlags.set({refreshTarget: true});
      if ( this.hasActiveHUD ) this.layer.hud.render();
    }

    // Broadcast the target change if it was not part of a group selection
    if ( !groupSelection ) user.broadcastActivity({targets: user.targets.ids});
  }


  /* -------------------------------------------- */

  /**
   * The external radius of the token in pixels.
   * @type {number}
   */
  get externalRadius() {
    const {width, height} = this.getSize();
    return Math.max(width, height) / 2;
  }

  /* -------------------------------------------- */

  /**
   * A generic transformation to turn a certain number of grid units into a radius in canvas pixels.
   * This function adds additional padding to the light radius equal to the external radius of the token.
   * This causes light to be measured from the outer token edge, rather than from the center-point.
   * @param {number} units  The radius in grid units
   * @returns {number}      The radius in pixels
   */
  getLightRadius(units) {
    if ( units === 0 ) return 0;
    return ((Math.abs(units) * canvas.dimensions.distancePixels) + this.externalRadius) * Math.sign(units);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getShiftedPosition(dx, dy) {
    const shifted = super._getShiftedPosition(dx, dy);
    const collides = this.checkCollision(this.getCenterPoint(shifted));
    return collides ? {x: this.document._source.x, y: this.document._source.y} : shifted;
  }

  /* -------------------------------------------- */

  /** @override */
  _updateRotation({angle, delta=0, snap=0}={}) {
    let degrees = Number.isNumeric(angle) ? angle : this.document.rotation + delta;
    const isHexRow = [CONST.GRID_TYPES.HEXODDR, CONST.GRID_TYPES.HEXEVENR].includes(canvas.grid.type);
    if ( isHexRow ) degrees -= 30;
    if ( snap > 0 ) degrees = degrees.toNearest(snap);
    if ( isHexRow ) degrees += 30;
    return Math.normalizeDegrees(degrees);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    this.initializeSources(); // Update vision and lighting sources
    if ( !game.user.isGM && this.isOwner && !this.document.hidden ) this.control({pan: true}); // Assume control
    canvas.perception.update({refreshOcclusion: true});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    const doc = this.document;

    // Record movement
    const positionChanged = ("x" in changed) || ("y" in changed);
    const rotationChanged = "rotation" in changed;
    const sizeChanged = ("width" in changed) || ("height" in changed);
    const elevationChanged = "elevation" in changed;
    if ( positionChanged || rotationChanged || sizeChanged ) {
      this.#recordPosition(positionChanged, rotationChanged, sizeChanged);
    }
    // Acquire or release Token control
    const hiddenChanged = "hidden" in changed;
    if ( hiddenChanged ) {
      if ( this.controlled && changed.hidden && !game.user.isGM ) this.release();
      else if ( (changed.hidden === false) && !canvas.tokens.controlled.length ) this.control({pan: true});
      if ( this.isOwner && (this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.OWNED) ) {
        canvas.perception.update({refreshOcclusion: true});
      }
    }

    // Automatically pan the canvas
    if ( positionChanged && this.controlled && (options.pan !== false) ) this.#panCanvas();

    // Process Combat Tracker changes
    if ( this.inCombat && ("name" in changed) ) game.combat.debounceSetup();

    // Texture and Ring changes
    const textureChanged = "texture" in changed;
    const ringEnabled = doc.ring.enabled;
    const ringChanged = "ring" in changed;
    const ringEnabledChanged = ringChanged && ("enabled" in changed.ring);
    const ringSubjectChanged = ringEnabled && ringChanged && ("subject" in changed.ring);
    const ringSubjectTextureChanged = ringSubjectChanged && ("texture" in changed.ring.subject);
    const ringVisualsChanged = ringEnabled && ringChanged && (("colors" in changed.ring) || ("effects" in changed.ring));

    // Handle animatable changes
    if ( options.animate === false ) this.stopAnimation({reset: false});
    else {
      const to = foundry.utils.filterObject(this._getAnimationData(), changed);
      // TODO: Can we find a solution that doesn't require special handling for hidden?
      if ( hiddenChanged ) to.alpha = doc.alpha;

      // We need to infer subject texture if ring is enabled and texture is changed
      if ( (ringEnabled || ringEnabledChanged) && !ringSubjectTextureChanged && textureChanged && ("src" in changed.texture)
        && !doc._source.ring.subject.texture ) {
        foundry.utils.mergeObject(to, {ring: {subject: {texture: doc.texture.src}}});
      }

      // Don't animate movement if teleport
      if ( options.teleport === true ) this.#handleTeleportAnimation(to);

      // Dispatch the animation
      this.animate(to, options.animation);
    }

    // Source and perception updates
    if ( hiddenChanged || elevationChanged || ("light" in changed) || ("sight" in changed) || ("detectionModes" in changed) ) {
      this.initializeSources();
    }
    if ( !game.user.isGM && this.controlled && (hiddenChanged || (("sight" in changed) && ("enabled" in changed.sight))) ) {
      for ( const token of this.layer.placeables ) {
        if ( (token !== this) && (!token.vision === token._isVisionSource()) ) token.initializeVisionSource();
      }
    }
    if ( hiddenChanged || elevationChanged ) {
      canvas.perception.update({refreshVision: true, refreshSounds: true, refreshOcclusion: true});
    }
    if ( "occludable" in changed ) canvas.perception.update({refreshOcclusionMask: true});

    // Incremental refresh
    this.renderFlags.set({
      redraw: ringEnabledChanged || ("actorId" in changed) || ("actorLink" in changed),
      refreshState: hiddenChanged || ("sort" in changed) || ("disposition" in changed) || ("displayBars" in changed) || ("displayName" in changed),
      refreshRotation: "lockRotation" in changed,
      refreshElevation: elevationChanged,
      refreshMesh: textureChanged && ("fit" in changed.texture),
      refreshShape: "hexagonalShape" in changed,
      refreshBars: ["displayBars", "bar1", "bar2"].some(k => k in changed),
      refreshNameplate: ["displayName", "name", "appendNumber", "prependAdjective"].some(k => k in changed),
      refreshRingVisuals: ringVisualsChanged
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    game.user.targets.delete(this);
    this.initializeSources({deleted: true});
    canvas.perception.update({refreshOcclusion: true});
    return super._onDelete(options, userId);
  }

  /* -------------------------------------------- */

  /**
   * When Token position or rotation changes, record the movement vector.
   * Update cached values for both #validPosition and #priorMovement.
   * @param {boolean} positionChange    Did the x/y position change?
   * @param {boolean} rotationChange    Did rotation change?
   * @param {boolean} sizeChange        Did the width or height change?
   */
  #recordPosition(positionChange, rotationChange, sizeChange) {

    // Update rotation
    const position = {};
    if ( rotationChange ) {
      position.rotation = this.document.rotation;
    }

    // Update movement vector
    if ( positionChange ) {
      const origin = {x: this.#animationData.x, y: this.#animationData.y};
      position.x = this.document.x;
      position.y = this.document.y;
      const ray = new Ray(origin, position);

      // Offset movement relative to prior vector
      const prior = this.#priorMovement;
      const ox = ray.dx === 0 ? prior.ox : Math.sign(ray.dx);
      const oy = ray.dy === 0 ? prior.oy : Math.sign(ray.dy);
      this.#priorMovement = {dx: ray.dx, dy: ray.dy, ox, oy};
    }

    // Update valid position
    foundry.utils.mergeObject(this.#validPosition, position);
  }

  /* -------------------------------------------- */

  /**
   * Automatically pan the canvas when a controlled Token moves offscreen.
   */
  #panCanvas() {

    // Target center point in screen coordinates
    const c = this.center;
    const {x: sx, y: sy} = canvas.stage.transform.worldTransform.apply(c);

    // Screen rectangle minus padding space
    const pad = 50;
    const sidebarPad = $("#sidebar").width() + pad;
    const rect = new PIXI.Rectangle(pad, pad, window.innerWidth - sidebarPad, window.innerHeight - pad);

    // Pan the canvas if the target center-point falls outside the screen rect
    if ( !rect.contains(sx, sy) ) canvas.animatePan(this.center);
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to Token behavior when a significant status effect is applied
   * @param {string} statusId       The status effect ID being applied, from CONFIG.specialStatusEffects
   * @param {boolean} active        Is the special status effect now active?
   * @protected
   * @internal
   */
  _onApplyStatusEffect(statusId, active) {
    switch ( statusId ) {
      case CONFIG.specialStatusEffects.BURROW:
        this.initializeSources();
        break;
      case CONFIG.specialStatusEffects.FLY:
      case CONFIG.specialStatusEffects.HOVER:
        canvas.perception.update({refreshVision: true});
        break;
      case CONFIG.specialStatusEffects.INVISIBLE:
        canvas.perception.update({refreshVision: true});
        this._configureFilterEffect(statusId, active);
        break;
      case CONFIG.specialStatusEffects.BLIND:
        this.initializeVisionSource();
        break;
    }

    // Call hooks
    Hooks.callAll("applyTokenStatusEffect", this, statusId, active);
  }

  /* -------------------------------------------- */

  /**
   * Add/Modify a filter effect on this token.
   * @param {string} statusId       The status effect ID being applied, from CONFIG.specialStatusEffects
   * @param {boolean} active        Is the special status effect now active?
   * @internal
   */
  _configureFilterEffect(statusId, active) {
    let filterClass = null;
    let filterUniforms = {};

    // TODO: The filter class should be into CONFIG with specialStatusEffects or conditions.
    switch ( statusId ) {
      case CONFIG.specialStatusEffects.INVISIBLE:
        filterClass = InvisibilityFilter;
        break;
    }
    if ( !filterClass ) return;

    const target = this.mesh;
    target.filters ??= [];

    // Is a filter active for this id?
    let filter = this.#filterEffects.get(statusId);
    if ( !filter && active ) {
      filter = filterClass.create(filterUniforms);

      // Push the filter and set the filter effects map
      target.filters.push(filter);
      this.#filterEffects.set(statusId, filter);
    }
    else if ( filter ) {
      filter.enabled = active;
      foundry.utils.mergeObject(filter.uniforms, filterUniforms, {
        insertKeys: false,
        overwrite: true,
        enforceTypes: true
      });
      if ( active && !target.filters.find(f => f === filter) ) target.filters.push(filter);
    }
  }

  /* -------------------------------------------- */

  /**
   * Update the filter effects depending on special status effects
   * TODO: replace this method by something more convenient.
   * @internal
   */
  _updateSpecialStatusFilterEffects() {
    const invisible = CONFIG.specialStatusEffects.INVISIBLE;
    this._configureFilterEffect(invisible, this.document.hasStatusEffect(invisible));
  }

  /* -------------------------------------------- */

  /**
   * Remove all filter effects on this placeable.
   * @internal
   */
  _removeAllFilterEffects() {
    const target = this.mesh;
    if ( target?.filters?.length ) {
      for ( const filterEffect of this.#filterEffects.values() ) {
        target.filters.findSplice(f => f === filterEffect);
      }
    }
    this.#filterEffects.clear();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onControl({releaseOthers=true, pan=false, ...options}={}) {
    super._onControl(options);
    for ( const token of this.layer.placeables ) {
      if ( !token.vision === token._isVisionSource() ) token.initializeVisionSource();
    }
    _token = this; // Debugging global window variable
    canvas.perception.update({
      refreshVision: true,
      refreshSounds: true,
      refreshOcclusion: this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.CONTROLLED
    });

    // Pan to the controlled Token
    if ( pan ) canvas.animatePan(this.center);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onRelease(options) {
    super._onRelease(options);
    for ( const token of this.layer.placeables ) {
      if ( !token.vision === token._isVisionSource() ) token.initializeVisionSource();
    }
    canvas.perception.update({
      refreshVision: true,
      refreshSounds: true,
      refreshOcclusion: this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.CONTROLLED
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _overlapsSelection(rectangle) {
    if ( !this.shape ) return false;
    const shape = this.shape;
    const isRectangle = shape instanceof PIXI.Rectangle;
    if ( !isRectangle && !rectangle.intersects(this.bounds) ) return false;
    const localRectangle = new PIXI.Rectangle(
      rectangle.x - this.document.x,
      rectangle.y - this.document.y,
      rectangle.width,
      rectangle.height
    );
    if ( isRectangle ) return localRectangle.intersects(shape);
    const shapePolygon = shape instanceof PIXI.Polygon ? shape : shape.toPolygon();
    const intersection = localRectangle.intersectPolygon(shapePolygon, {scalingFactor: 100});
    return intersection.points.length !== 0;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _canControl(user, event) {
    if ( !this.layer.active || this.isPreview ) return false;
    if ( canvas.controls.ruler.state === Ruler.STATES.MEASURING ) return false;
    const tool = game.activeTool;
    if ( (tool === "target") && !this.isPreview ) return true;
    return super._canControl(user, event);
  }

  /* -------------------------------------------- */

  /** @override */
  _canHUD(user, event) {
    if ( canvas.controls.ruler.state === Ruler.STATES.MEASURING ) return false;
    return user.isGM || (this.actor?.testUserPermission(user, "OWNER") ?? false);
  }

  /* -------------------------------------------- */

  /** @override */
  _canConfigure(user, event) {
    if ( canvas.controls.ruler.state === Ruler.STATES.MEASURING ) return false;
    return !this.isPreview;
  }

  /* -------------------------------------------- */

  /** @override */
  _canHover(user, event) {
    return !this.isPreview;
  }

  /* -------------------------------------------- */

  /** @override */
  _canView(user, event) {
    if ( canvas.controls.ruler.state === Ruler.STATES.MEASURING ) return false;
    if ( !this.actor ) ui.notifications.warn("TOKEN.WarningNoActor", {localize: true});
    return this.actor?.testUserPermission(user, "LIMITED");
  }

  /* -------------------------------------------- */

  /** @override */
  _canDrag(user, event) {
    if ( !this.controlled ) return false;
    if ( !this.layer.active || (game.activeTool !== "select") ) return false;
    const ruler = canvas.controls.ruler;
    if ( ruler.state === Ruler.STATES.MEASURING ) return false;
    if ( ruler.token === this ) return false;
    if ( CONFIG.Canvas.rulerClass.canMeasure ) return false;
    return true;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onHoverIn(event, options) {
    const combatant = this.combatant;
    if ( combatant ) ui.combat.hoverCombatant(combatant, true);
    if ( this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.HOVERED ) {
      canvas.perception.update({refreshOcclusion: true});
    }
    return super._onHoverIn(event, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onHoverOut(event) {
    const combatant = this.combatant;
    if ( combatant ) ui.combat.hoverCombatant(combatant, false);
    if ( this.layer.occlusionMode & CONST.TOKEN_OCCLUSION_MODES.HOVERED ) {
      canvas.perception.update({refreshOcclusion: true});
    }
    return super._onHoverOut(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickLeft(event) {
    const tool = game.activeTool;
    if ( tool === "target" ) {
      event.stopPropagation();
      if ( this.document.isSecret ) return;
      return this.setTarget(!this.isTargeted, {releaseOthers: !event.shiftKey});
    }
    super._onClickLeft(event);
  }

  /** @override */
  _propagateLeftClick(event) {
    return CONFIG.Canvas.rulerClass.canMeasure;
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickLeft2(event) {
    if ( !this._propagateLeftClick(event) ) event.stopPropagation();
    const sheet = this.actor?.sheet;
    if ( sheet?.rendered ) {
      sheet.maximize();
      sheet.bringToTop();
    }
    else sheet?.render(true, {token: this.document});
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickRight2(event) {
    if ( !this._propagateRightClick(event) ) event.stopPropagation();
    if ( this.isOwner && game.user.can("TOKEN_CONFIGURE") ) return super._onClickRight2(event);
    if ( this.document.isSecret ) return;
    return this.setTarget(!this.targeted.has(game.user), {releaseOthers: !event.shiftKey});
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftStart(event) {
    const currentX = this.#animationData.x;
    const currentY = this.#animationData.y;
    this.stopAnimation();
    const origin = event.interactionData.origin;
    origin.x += (this.document.x - currentX);
    origin.y += (this.document.y - currentY);
    return super._onDragLeftStart(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareDragLeftDropUpdates(event) {
    const updates = [];
    for ( const clone of event.interactionData.clones ) {
      const {document, _original: original} = clone;
      const dest = !event.shiftKey ? clone.getSnappedPosition() : {x: document.x, y: document.y};
      const target = clone.getCenterPoint(dest);
      if ( !game.user.isGM ) {
        let collides = original.checkCollision(target);
        if ( collides ) {
          ui.notifications.error("RULER.MovementCollision", {localize: true, console: false});
          continue;
        }
      }
      else if ( !canvas.dimensions.rect.contains(target.x, target.y) ) continue;
      updates.push({_id: original.id, x: dest.x, y: dest.y});
    }
    return updates;
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftMove(event) {
    const {destination, clones} = event.interactionData;
    const preview = game.settings.get("core", "tokenDragPreview");

    // Pan the canvas if the drag event approaches the edge
    canvas._onDragCanvasPan(event);

    // Determine dragged distance
    const origin = this.getCenterPoint();
    const dx = destination.x - origin.x;
    const dy = destination.y - origin.y;

    // Update the position of each clone
    for ( const c of clones ) {
      const o = c._original;
      let position = {x: o.document.x + dx, y: o.document.y + dy};
      if ( !event.shiftKey ) position = c.getSnappedPosition(position);
      if ( preview && !game.user.isGM ) {
        const collision = o.checkCollision(o.getCenterPoint(position));
        if ( collision ) continue;
      }
      c.document.x = position.x;
      c.document.y = position.y;
      c.renderFlags.set({refreshPosition: true});
      if ( preview ) c.initializeSources();
    }

    // Update perception immediately
    if ( preview ) canvas.perception.update({refreshLighting: true, refreshVision: true});
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragEnd() {
    this.initializeSources({deleted: true});
    this._original?.initializeSources();
    super._onDragEnd();
  }

  /* -------------------------------------------- */
  /*  Hexagonal Shape Helpers                     */
  /* -------------------------------------------- */

  /**
   * A hexagonal shape of a Token.
   * @typedef {object} TokenHexagonalShape
   * @property {number[]} points    The points in normalized coordinates
   * @property {Point} center       The center of the shape in normalized coordiantes
   * @property {{behavior: GridSnappingBehavior, anchor: Point}} snapping
   *   The snapping behavior and snapping anchor in normalized coordinates
   */

  /**
   * The cache of hexagonal shapes.
   * @type {Map<string, DeepReadonly<TokenHexagonalShape>>}
   */
  static #hexagonalShapes = new Map();

  /* -------------------------------------------- */

  /**
   * Get the hexagonal shape given the type, width, and height.
   * @param {boolean} columns    Column-based instead of row-based hexagonal grid?
   * @param {number} type        The hexagonal shape (one of {@link CONST.TOKEN_HEXAGONAL_SHAPES})
   * @param {number} width       The width of the Token (positive)
   * @param {number} height      The height of the Token (positive)
   * @returns {DeepReadonly<TokenHexagonalShape>|null}    The hexagonal shape or null if there is no shape
   *                                                      for the given combination of arguments
   */
  static #getHexagonalShape(columns, type, width, height) {
    if ( !Number.isInteger(width * 2) || !Number.isInteger(height * 2) ) return null;
    const key = `${columns ? "C" : "R"},${type},${width},${height}`;
    let shape = Token.#hexagonalShapes.get(key);
    if ( shape ) return shape;
    const T = CONST.TOKEN_HEXAGONAL_SHAPES;
    const M = CONST.GRID_SNAPPING_MODES;

    // Hexagon symmetry
    if ( columns ) {
      const rowShape = Token.#getHexagonalShape(false, type, height, width);
      if ( !rowShape ) return null;

      // Transpose and reverse the points of the shape in row orientation
      const points = [];
      for ( let i = rowShape.points.length; i > 0; i -= 2 ) {
        points.push(rowShape.points[i - 1], rowShape.points[i - 2]);
      }
      shape = {
        points,
        center: {x: rowShape.center.y, y: rowShape.center.x},
        snapping: {
          behavior: rowShape.snapping.behavior,
          anchor: {x: rowShape.snapping.anchor.y, y: rowShape.snapping.anchor.x}
        }
      };
    }

    // Small hexagon
    else if ( (width === 0.5) && (height === 0.5) ) {
      shape = {
        points: [0.25, 0.0, 0.5, 0.125, 0.5, 0.375, 0.25, 0.5, 0.0, 0.375, 0.0, 0.125],
        center: {x: 0.25, y: 0.25},
        snapping: {behavior: {mode: M.CENTER, resolution: 1}, anchor: {x: 0.25, y: 0.25}}
      };
    }

    // Normal hexagon
    else if ( (width === 1) && (height === 1) ) {
      shape = {
        points: [0.5, 0.0, 1.0, 0.25, 1, 0.75, 0.5, 1.0, 0.0, 0.75, 0.0, 0.25],
        center: {x: 0.5, y: 0.5},
        snapping: {behavior: {mode: M.TOP_LEFT_CORNER, resolution: 1}, anchor: {x: 0.0, y: 0.0}}
      };
    }

    // Hexagonal ellipse or trapezoid
    else if ( type <= T.TRAPEZOID_2 ) {
      shape = Token.#createHexagonalEllipseOrTrapezoid(type, width, height);
    }

    // Hexagonal rectangle
    else if ( type <= T.RECTANGLE_2 ) {
      shape = Token.#createHexagonalRectangle(type, width, height);
    }

    // Cache the shape
    if ( shape ) {
      Object.freeze(shape);
      Object.freeze(shape.points);
      Object.freeze(shape.center);
      Object.freeze(shape.snapping);
      Object.freeze(shape.snapping.behavior);
      Object.freeze(shape.snapping.anchor);
      Token.#hexagonalShapes.set(key, shape);
    }
    return shape;
  }

  /* -------------------------------------------- */

  /**
   * Create the row-based hexagonal ellipse/trapezoid given the type, width, and height.
   * @param {number} type                   The shape type (must be ELLIPSE_1, ELLIPSE_1, TRAPEZOID_1, or TRAPEZOID_2)
   * @param {number} width                  The width of the Token (positive)
   * @param {number} height                 The height of the Token (positive)
   * @returns {TokenHexagonalShape|null}    The hexagonal shape or null if there is no shape
   *                                        for the given combination of arguments
   */
  static #createHexagonalEllipseOrTrapezoid(type, width, height) {
    if ( !Number.isInteger(width) || !Number.isInteger(height) ) return null;
    const T = CONST.TOKEN_HEXAGONAL_SHAPES;
    const M = CONST.GRID_SNAPPING_MODES;
    const points = [];
    let top;
    let bottom;
    switch ( type ) {
      case T.ELLIPSE_1:
        if ( height >= 2 * width ) return null;
        top = Math.floor(height / 2);
        bottom = Math.floor((height - 1) / 2);
        break;
      case T.ELLIPSE_2:
        if ( height >= 2 * width ) return null;
        top = Math.floor((height - 1) / 2);
        bottom = Math.floor(height / 2);
        break;
      case T.TRAPEZOID_1:
        if ( height > width ) return null;
        top = height - 1;
        bottom = 0;
        break;
      case T.TRAPEZOID_2:
        if ( height > width ) return null;
        top = 0;
        bottom = height - 1;
        break;
    }
    let x = 0.5 * bottom;
    let y = 0.25;
    for ( let k = width - bottom; k--; ) {
      points.push(x, y);
      x += 0.5;
      y -= 0.25;
      points.push(x, y);
      x += 0.5;
      y += 0.25;
    }
    points.push(x, y);
    for ( let k = bottom; k--; ) {
      y += 0.5;
      points.push(x, y);
      x += 0.5;
      y += 0.25;
      points.push(x, y);
    }
    y += 0.5;
    for ( let k = top; k--; ) {
      points.push(x, y);
      x -= 0.5;
      y += 0.25;
      points.push(x, y);
      y += 0.5;
    }
    for ( let k = width - top; k--; ) {
      points.push(x, y);
      x -= 0.5;
      y += 0.25;
      points.push(x, y);
      x -= 0.5;
      y -= 0.25;
    }
    points.push(x, y);
    for ( let k = top; k--; ) {
      y -= 0.5;
      points.push(x, y);
      x -= 0.5;
      y -= 0.25;
      points.push(x, y);
    }
    y -= 0.5;
    for ( let k = bottom; k--; ) {
      points.push(x, y);
      x += 0.5;
      y -= 0.25;
      points.push(x, y);
      y -= 0.5;
    }
    return {
      points,
      // We use the centroid of the polygon for ellipse and trapzoid shapes
      center: foundry.utils.polygonCentroid(points),
      snapping: {
        behavior: {mode: bottom % 2 ? M.BOTTOM_RIGHT_VERTEX : M.TOP_LEFT_CORNER, resolution: 1},
        anchor: {x: 0.0, y: 0.0}
      }
    };
  }

  /**
   * Create the row-based hexagonal rectangle given the type, width, and height.
   * @param {number} type                   The shape type (must be RECTANGLE_1 or RECTANGLE_2)
   * @param {number} width                  The width of the Token (positive)
   * @param {number} height                 The height of the Token (positive)
   * @returns {TokenHexagonalShape|null}    The hexagonal shape or null if there is no shape
   *                                        for the given combination of arguments
   */
  static #createHexagonalRectangle(type, width, height) {
    if ( (width < 1) || !Number.isInteger(height) ) return null;
    if ( (width === 1) && (height > 1) ) return null;
    if ( !Number.isInteger(width) && (height === 1) ) return null;
    const T = CONST.TOKEN_HEXAGONAL_SHAPES;
    const M = CONST.GRID_SNAPPING_MODES;
    const even = (type === T.RECTANGLE_1) || (height === 1);
    let x = even ? 0.0 : 0.5;
    let y = 0.25;
    const points = [x, y];
    while ( x + 1 <= width ) {
      x += 0.5;
      y -= 0.25;
      points.push(x, y);
      x += 0.5;
      y += 0.25;
      points.push(x, y);
    }
    if ( x !== width ) {
      y += 0.5;
      points.push(x, y);
      x += 0.5;
      y += 0.25;
      points.push(x, y);
    }
    while ( y + 1.5 <= 0.75 * height ) {
      y += 0.5;
      points.push(x, y);
      x -= 0.5;
      y += 0.25;
      points.push(x, y);
      y += 0.5;
      points.push(x, y);
      x += 0.5;
      y += 0.25;
      points.push(x, y);
    }
    if ( y + 0.75 < 0.75 * height ) {
      y += 0.5;
      points.push(x, y);
      x -= 0.5;
      y += 0.25;
      points.push(x, y);
    }
    y += 0.5;
    points.push(x, y);
    while ( x - 1 >= 0 ) {
      x -= 0.5;
      y += 0.25;
      points.push(x, y);
      x -= 0.5;
      y -= 0.25;
      points.push(x, y);
    }
    if ( x !== 0 ) {
      y -= 0.5;
      points.push(x, y);
      x -= 0.5;
      y -= 0.25;
      points.push(x, y);
    }
    while ( y - 1.5 > 0 ) {
      y -= 0.5;
      points.push(x, y);
      x += 0.5;
      y -= 0.25;
      points.push(x, y);
      y -= 0.5;
      points.push(x, y);
      x -= 0.5;
      y -= 0.25;
      points.push(x, y);
    }
    if ( y - 0.75 > 0 ) {
      y -= 0.5;
      points.push(x, y);
      x += 0.5;
      y -= 0.25;
      points.push(x, y);
    }
    return {
      points,
      // We use center of the rectangle (and not the centroid of the polygon) for the rectangle shapes
      center: {
        x: width / 2,
        y: ((0.75 * Math.floor(height)) + (0.5 * (height % 1)) + 0.25) / 2
      },
      snapping: {
        behavior: {mode: even ? M.TOP_LEFT_CORNER : M.BOTTOM_RIGHT_VERTEX, resolution: 1},
        anchor: {x: 0.0, y: 0.0}
      }
    };
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  updatePosition() {
    const msg = "Token#updatePosition has been deprecated without replacement as it is no longer required.";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
  }

  /**
   * @deprecated since 11
   * @ignore
   */
  refreshHUD({bars=true, border=true, effects=true, elevation=true, nameplate=true}={}) {
    const msg = "Token#refreshHUD is deprecated in favor of token.renderFlags.set()";
    foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
    this.renderFlags.set({
      refreshBars: bars,
      refreshBorder: border,
      refreshElevation: elevation,
      refreshNameplate: nameplate,
      redrawEffects: effects
    });
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  updateSource({deleted=false}={}) {
    const msg = "Token#updateSource has been deprecated in favor of Token#initializeSources";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    this.initializeSources({deleted});
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  getCenter(x, y) {
    const msg = "Token#getCenter(x, y) has been deprecated in favor of Token#getCenterPoint(Point).";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
    return this.getCenterPoint(x !== undefined ? {x, y} : this.document);
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  get owner() {
    const msg = "Token#owner has been deprecated. Use Token#isOwner instead.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.isOwner;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  async toggleCombat(combat) {
    foundry.utils.logCompatibilityWarning("Token#toggleCombat is deprecated in favor of TokenDocument#toggleCombatant,"
      + " TokenDocument.implementation.createCombatants, and TokenDocument.implementation.deleteCombatants", {since: 12, until: 14});
    const tokens = canvas.tokens.controlled.map(t => t.document);
    if ( !this.controlled ) tokens.push(this.document);
    if ( this.inCombat ) await TokenDocument.implementation.deleteCombatants(tokens);
    else await TokenDocument.implementation.createCombatants(tokens);
  }


  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  async toggleEffect(effect, {active, overlay=false}={}) {
    foundry.utils.logCompatibilityWarning("Token#toggleEffect is deprecated in favor of Actor#toggleStatusEffect",
      {since: 12, until: 14});
    if ( !this.actor || !effect.id ) return false;
    return this.actor.toggleStatusEffect(effect.id, {active, overlay});
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  async toggleVisibility() {
    foundry.utils.logCompatibilityWarning("Token#toggleVisibility is deprecated without replacement in favor of"
      + " updating the hidden field of the TokenDocument directly.", {since: 12, until: 14});
    let isHidden = this.document.hidden;
    const tokens = this.controlled ? canvas.tokens.controlled : [this];
    const updates = tokens.map(t => { return {_id: t.id, hidden: !isHidden};});
    return canvas.scene.updateEmbeddedDocuments("Token", updates);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12 Stable 4
   * @ignore
   */
  _recoverFromPreview() {
    foundry.utils.logCompatibilityWarning("Token#_recoverFromPreview is deprecated without replacement in favor of"
      + " recovering from preview directly into TokenConfig#_resetPreview.", {since: 12, until: 14});
    this.renderable = true;
    this.initializeSources();
    this.control();
  }
}

/**
 * A "secret" global to help debug attributes of the currently controlled Token.
 * This is only for debugging, and may be removed in the future, so it's not safe to use.
 * @type {Token}
 * @ignore
 */
let _token = null;

/**
 * A Wall is an implementation of PlaceableObject which represents a physical or visual barrier within the Scene.
 * Walls are used to restrict Token movement or visibility as well as to define the areas of effect for ambient lights
 * and sounds.
 * @category - Canvas
 * @see {@link WallDocument}
 * @see {@link WallsLayer}
 */
class Wall extends PlaceableObject {
  constructor(document) {
    super(document);
    this.#edge = this.#createEdge();
    this.#priorDoorState = this.document.ds;
  }

  /** @inheritdoc */
  static embeddedName = "Wall";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshLine"], alias: true},
    refreshState: {propagate: ["refreshEndpoints", "refreshHighlight"]},
    refreshLine: {propagate: ["refreshEndpoints", "refreshHighlight", "refreshDirection"]},
    refreshEndpoints: {},
    refreshDirection: {},
    refreshHighlight: {}
  };

  /**
   * A reference the Door Control icon associated with this Wall, if any
   * @type {DoorControl|null}
   */
  doorControl;

  /**
   * The line segment that represents the Wall.
   * @type {PIXI.Graphics}
   */
  line;

  /**
   * The endpoints of the Wall line segment.
   * @type {PIXI.Graphics}
   */
  endpoints;

  /**
   * The icon that indicates the direction of the Wall.
   * @type {PIXI.Sprite|null}
   */
  directionIcon;

  /**
   * A Graphics object used to highlight this wall segment. Only used when the wall is controlled.
   * @type {PIXI.Graphics}
   */
  highlight;

  /**
   * Cache the prior door state so that we can identify changes in the door state.
   * @type {number}
   */
  #priorDoorState;

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A convenience reference to the coordinates Array for the Wall endpoints, [x0,y0,x1,y1].
   * @type {number[]}
   */
  get coords() {
    return this.document.c;
  }

  /* -------------------------------------------- */

  /**
   * The Edge instance which represents this Wall.
   * The Edge is re-created when data for the Wall changes.
   * @type {Edge}
   */
  get edge() {
    return this.#edge;
  }

  #edge;

  /* -------------------------------------------- */

  /** @inheritdoc */
  get bounds() {
    const [x0, y0, x1, y1] = this.document.c;
    return new PIXI.Rectangle(x0, y0, x1-x0, y1-y0).normalize();
  }

  /* -------------------------------------------- */

  /**
   * A boolean for whether this wall contains a door
   * @type {boolean}
   */
  get isDoor() {
    return this.document.door > CONST.WALL_DOOR_TYPES.NONE;
  }

  /* -------------------------------------------- */

  /**
   * A boolean for whether the wall contains an open door
   * @returns {boolean}
   */
  get isOpen() {
    return this.isDoor && (this.document.ds === CONST.WALL_DOOR_STATES.OPEN);
  }

  /* -------------------------------------------- */

  /**
   * Return the coordinates [x,y] at the midpoint of the wall segment
   * @returns {Array<number>}
   */
  get midpoint() {
    return [(this.coords[0] + this.coords[2]) / 2, (this.coords[1] + this.coords[3]) / 2];
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get center() {
    const [x, y] = this.midpoint;
    return new PIXI.Point(x, y);
  }

  /* -------------------------------------------- */

  /**
   * Get the direction of effect for a directional Wall
   * @type {number|null}
   */
  get direction() {
    let d = this.document.dir;
    if ( !d ) return null;
    let c = this.coords;
    let angle = Math.atan2(c[3] - c[1], c[2] - c[0]);
    if ( d === CONST.WALL_DIRECTIONS.LEFT ) return angle + (Math.PI / 2);
    else return angle - (Math.PI / 2);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @override */
  getSnappedPosition(position) {
    throw new Error("Wall#getSnappedPosition is not supported: WallDocument does not have a (x, y) position");
  }

  /* -------------------------------------------- */

  /**
   * Initialize the edge which represents this Wall.
   * @param {object} [options]              Options which modify how the edge is initialized
   * @param {boolean} [options.deleted]     Has the edge been deleted?
   */
  initializeEdge({deleted=false}={}) {

    // The wall has been deleted
    if ( deleted ) {
      this.#edge = null;
      canvas.edges.delete(this.id);
      return;
    }

    // Re-create the Edge for the wall
    this.#edge = this.#createEdge();
    canvas.edges.set(this.id, this.#edge);
  }

  /* -------------------------------------------- */

  /**
   * Create an Edge from the Wall placeable.
   * @returns {Edge}
   */
  #createEdge() {
    let {c, light, sight, sound, move, dir, threshold} = this.document;
    if ( this.isOpen ) light = sight = sound = move = CONST.WALL_SENSE_TYPES.NONE;
    const dpx = this.scene.dimensions.distancePixels;
    return new foundry.canvas.edges.Edge({x: c[0], y: c[1]}, {x: c[2], y: c[3]}, {
      id: this.id,
      object: this,
      type: "wall",
      direction: dir,
      light,
      sight,
      sound,
      move,
      threshold: {
        light: threshold.light * dpx,
        sight: threshold.sight * dpx,
        sound: threshold.sound * dpx,
        attenuation: threshold.attenuation
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * This helper converts the wall segment to a Ray
   * @returns {Ray}    The wall in Ray representation
   */
  toRay() {
    return Ray.fromArrays(this.coords.slice(0, 2), this.coords.slice(2));
  }

  /* -------------------------------------------- */

  /** @override */
  async _draw(options) {
    this.line = this.addChild(new PIXI.Graphics());
    this.line.eventMode = "auto";
    this.directionIcon = this.addChild(this.#drawDirection());
    this.directionIcon.eventMode = "none";
    this.endpoints = this.addChild(new PIXI.Graphics());
    this.endpoints.eventMode = "auto";
    this.cursor = "pointer";
  }

  /* -------------------------------------------- */

  /** @override */
  clear() {
    this.clearDoorControl();
    return super.clear();
  }

  /* -------------------------------------------- */

  /**
   * Draw a control icon that is used to manipulate the door's open/closed state
   * @returns {DoorControl}
   */
  createDoorControl() {
    if ((this.document.door === CONST.WALL_DOOR_TYPES.SECRET) && !game.user.isGM) return null;
    this.doorControl = canvas.controls.doors.addChild(new CONFIG.Canvas.doorControlClass(this));
    this.doorControl.draw();
    return this.doorControl;
  }

  /* -------------------------------------------- */

  /**
   * Clear the door control if it exists.
   */
  clearDoorControl() {
    if ( this.doorControl ) {
      this.doorControl.destroy({children: true});
      this.doorControl = null;
    }
  }

  /* -------------------------------------------- */

  /**
   * Draw a directional prompt icon for one-way walls to illustrate their direction of effect.
   * @returns {PIXI.Sprite|null}   The drawn icon
   */
  #drawDirection() {
    if ( this.directionIcon ) return null;

    // Create the icon
    const tex = getTexture(CONFIG.controlIcons.wallDirection);
    const icon = new PIXI.Sprite(tex);

    // Set icon initial state
    icon.width = icon.height = 32;
    icon.anchor.set(0.5, 0.5);
    icon.visible = false;
    return icon;
  }

  /* -------------------------------------------- */

  /**
   * Compute an approximate Polygon which encloses the line segment providing a specific hitArea for the line
   * @param {number} pad          The amount of padding to apply
   * @returns {PIXI.Polygon}      A constructed Polygon for the line
   */
  #getHitPolygon(pad) {
    const c = this.document.c;

    // Identify wall orientation
    const dx = c[2] - c[0];
    const dy = c[3] - c[1];

    // Define the array of polygon points
    let points;
    if ( Math.abs(dx) >= Math.abs(dy) ) {
      const sx = Math.sign(dx);
      points = [
        c[0]-(pad*sx), c[1]-pad,
        c[2]+(pad*sx), c[3]-pad,
        c[2]+(pad*sx), c[3]+pad,
        c[0]-(pad*sx), c[1]+pad
      ];
    } else {
      const sy = Math.sign(dy);
      points = [
        c[0]-pad, c[1]-(pad*sy),
        c[2]-pad, c[3]+(pad*sy),
        c[2]+pad, c[3]+(pad*sy),
        c[0]+pad, c[1]-(pad*sy)
      ];
    }

    // Return a Polygon which pads the line
    return new PIXI.Polygon(points);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  control({chain=false, ...options}={}) {
    const controlled = super.control(options);
    if ( controlled && chain ) {
      const links = this.getLinkedSegments();
      for ( let l of links.walls ) {
        l.control({releaseOthers: false});
        this.layer.controlledObjects.set(l.id, l);
      }
    }
    return controlled;
  }

  /* -------------------------------------------- */

  /** @override */
  _destroy(options) {
    this.clearDoorControl();
  }

  /* -------------------------------------------- */

  /**
   * Test whether the Wall direction lies between two provided angles
   * This test is used for collision and vision checks against one-directional walls
   * @param {number} lower    The lower-bound limiting angle in radians
   * @param {number} upper    The upper-bound limiting angle in radians
   * @returns {boolean}
   */
  isDirectionBetweenAngles(lower, upper) {
    let d = this.direction;
    if ( d < lower ) {
      while ( d < lower ) d += (2 * Math.PI);
    } else if ( d > upper ) {
      while ( d > upper ) d -= (2 * Math.PI);
    }
    return ( d > lower && d < upper );
  }

  /* -------------------------------------------- */

  /**
   * A simple test for whether a Ray can intersect a directional wall
   * @param {Ray} ray     The ray to test
   * @returns {boolean}    Can an intersection occur?
   */
  canRayIntersect(ray) {
    if ( this.direction === null ) return true;
    return this.isDirectionBetweenAngles(ray.angle - (Math.PI/2), ray.angle + (Math.PI/2));
  }

  /* -------------------------------------------- */

  /**
   * Get an Array of Wall objects which are linked by a common coordinate
   * @returns {Object}    An object reporting ids and endpoints of the linked segments
   */
  getLinkedSegments() {
    const test = new Set();
    const done = new Set();
    const ids = new Set();
    const objects = [];

    // Helper function to add wall points to the set
    const _addPoints = w => {
      let p0 = w.coords.slice(0, 2).join(".");
      if ( !done.has(p0) ) test.add(p0);
      let p1 = w.coords.slice(2).join(".");
      if ( !done.has(p1) ) test.add(p1);
    };

    // Helper function to identify other walls which share a point
    const _getWalls = p => {
      return canvas.walls.placeables.filter(w => {
        if ( ids.has(w.id) ) return false;
        let p0 = w.coords.slice(0, 2).join(".");
        let p1 = w.coords.slice(2).join(".");
        return ( p === p0 ) || ( p === p1 );
      });
    };

    // Seed the initial search with this wall's points
    _addPoints(this);

    // Begin recursively searching
    while ( test.size > 0 ) {
      const testIds = [...test];
      for ( let p of testIds ) {
        let walls = _getWalls(p);
        walls.forEach(w => {
          _addPoints(w);
          if ( !ids.has(w.id) ) objects.push(w);
          ids.add(w.id);
        });
        test.delete(p);
        done.add(p);
      }
    }

    // Return the wall IDs and their endpoints
    return {
      ids: [...ids],
      walls: objects,
      endpoints: [...done].map(p => p.split(".").map(Number))
    };
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @override */
  _applyRenderFlags(flags) {
    if ( flags.refreshState ) this._refreshState();
    if ( flags.refreshLine ) this._refreshLine();
    if ( flags.refreshEndpoints ) this._refreshEndpoints();
    if ( flags.refreshDirection ) this._refreshDirection();
    if ( flags.refreshHighlight ) this._refreshHighlight();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the displayed position of the wall which refreshes when the wall coordinates or type changes.
   * @protected
   */
  _refreshLine() {
    const c = this.document.c;
    const wc = this._getWallColor();
    const lw = Wall.#getLineWidth();

    // Draw line
    this.line.clear()
      .lineStyle(lw * 3, 0x000000, 1.0)  // Background black
      .moveTo(c[0], c[1])
      .lineTo(c[2], c[3]);
    this.line.lineStyle(lw, wc, 1.0)  // Foreground color
      .lineTo(c[0], c[1]);

    // Tint direction icon
    if ( this.directionIcon ) {
      this.directionIcon.position.set((c[0] + c[2]) / 2, (c[1] + c[3]) / 2);
      this.directionIcon.tint = wc;
    }

    // Re-position door control icon
    if ( this.doorControl ) this.doorControl.reposition();

    // Update hit area for interaction
    const priorHitArea = this.line.hitArea;
    this.line.hitArea = this.#getHitPolygon(lw * 3);
    if ( !priorHitArea
      || (this.line.hitArea.x !== priorHitArea.x)
      || (this.line.hitArea.y !== priorHitArea.y)
      || (this.line.hitArea.width !== priorHitArea.width)
      || (this.line.hitArea.height !== priorHitArea.height) ) {
      MouseInteractionManager.emulateMoveEvent();
    }
  }

  /* -------------------------------------------- */

  /**
   * Refresh the display of wall endpoints which refreshes when the wall position or state changes.
   * @protected
   */
  _refreshEndpoints() {
    const c = this.coords;
    const wc = this._getWallColor();
    const lw = Wall.#getLineWidth();
    const cr = (this.hover || this.layer.highlightObjects) ? lw * 4 : lw * 3;
    this.endpoints.clear()
      .lineStyle(lw, 0x000000, 1.0)
      .beginFill(wc, 1.0)
      .drawCircle(c[0], c[1], cr)
      .drawCircle(c[2], c[3], cr)
      .endFill();
  }

  /* -------------------------------------------- */

  /**
   * Draw a directional prompt icon for one-way walls to illustrate their direction of effect.
   * @protected
   */
  _refreshDirection() {
    if ( !this.document.dir ) return this.directionIcon.visible = false;

    // Set icon state and rotation
    const icon = this.directionIcon;
    const iconAngle = -Math.PI / 2;
    const angle = this.direction;
    icon.rotation = iconAngle + angle;
    icon.visible = true;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the appearance of the wall control highlight graphic. Occurs when wall control or position changes.
   * @protected
   */
  _refreshHighlight() {

    // Remove highlight
    if ( !this.controlled ) {
      if ( this.highlight ) {
        this.removeChild(this.highlight).destroy();
        this.highlight = undefined;
      }
      return;
    }

    // Add highlight
    if ( !this.highlight ) {
      this.highlight = this.addChildAt(new PIXI.Graphics(), 0);
      this.highlight.eventMode = "none";
    }
    else this.highlight.clear();

    // Configure highlight
    const c = this.coords;
    const lw = Wall.#getLineWidth();
    const cr = lw * 2;
    let cr2 = cr * 2;
    let cr4 = cr * 4;

    // Draw highlight
    this.highlight.lineStyle({width: cr, color: 0xFF9829})
      .drawRoundedRect(c[0] - cr2, c[1] - cr2, cr4, cr4, cr)
      .drawRoundedRect(c[2] - cr2, c[3] - cr2, cr4, cr4, cr)
      .lineStyle({width: cr2, color: 0xFF9829})
      .moveTo(c[0], c[1]).lineTo(c[2], c[3]);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the displayed state of the Wall.
   * @protected
   */
  _refreshState() {
    this.alpha = this._getTargetAlpha();
    this.zIndex = this.controlled ? 2 : this.hover ? 1 : 0;
  }

  /* -------------------------------------------- */

  /**
   * Given the properties of the wall - decide upon a color to render the wall for display on the WallsLayer
   * @returns {number}
   * @protected
   */
  _getWallColor() {
    const senses = CONST.WALL_SENSE_TYPES;

    // Invisible Walls
    if ( this.document.sight === senses.NONE ) return 0x77E7E8;

    // Terrain Walls
    else if ( this.document.sight === senses.LIMITED ) return 0x81B90C;

    // Windows (Sight Proximity)
    else if ( [senses.PROXIMITY, senses.DISTANCE].includes(this.document.sight) ) return 0xc7d8ff;

    // Ethereal Walls
    else if ( this.document.move === senses.NONE ) return 0xCA81FF;

    // Doors
    else if ( this.document.door === CONST.WALL_DOOR_TYPES.DOOR ) {
      let ds = this.document.ds || CONST.WALL_DOOR_STATES.CLOSED;
      if ( ds === CONST.WALL_DOOR_STATES.CLOSED ) return 0x6666EE;
      else if ( ds === CONST.WALL_DOOR_STATES.OPEN ) return 0x66CC66;
      else if ( ds === CONST.WALL_DOOR_STATES.LOCKED ) return 0xEE4444;
    }

    // Secret Doors
    else if ( this.document.door === CONST.WALL_DOOR_TYPES.SECRET ) {
      let ds = this.document.ds || CONST.WALL_DOOR_STATES.CLOSED;
      if ( ds === CONST.WALL_DOOR_STATES.CLOSED ) return 0xA612D4;
      else if ( ds === CONST.WALL_DOOR_STATES.OPEN ) return 0x7C1A9b;
      else if ( ds === CONST.WALL_DOOR_STATES.LOCKED ) return 0xEE4444;
    }

    // Standard Walls
    return 0xFFFFBB;
  }

  /* -------------------------------------------- */

  /**
   * Adapt the width that the wall should be rendered based on the grid size.
   * @returns {number}
   */
  static #getLineWidth() {
    const s = canvas.dimensions.size;
    if ( s > 150 ) return 4;
    else if ( s > 100 ) return 3;
    return 2;
  }

  /* -------------------------------------------- */
  /*  Socket Listeners and Handlers               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    this.layer._cloneType = this.document.toJSON();
    this.initializeEdge();
    this.#onModifyWall(this.document.door !== CONST.WALL_DOOR_TYPES.NONE);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Update the clone tool wall data
    this.layer._cloneType = this.document.toJSON();

    // Handle wall changes which require perception changes.
    const edgeChange = ("c" in changed) || CONST.WALL_RESTRICTION_TYPES.some(k => k in changed)
      || ("dir" in changed) || ("threshold" in changed);
    const doorChange = ["door", "ds"].some(k => k in changed);
    if ( edgeChange || doorChange ) {
      this.initializeEdge();
      this.#onModifyWall(doorChange);
    }

    // Trigger door interaction sounds
    if ( "ds" in changed ) {
      const states = CONST.WALL_DOOR_STATES;
      let interaction;
      if ( changed.ds === states.LOCKED ) interaction = "lock";
      else if ( changed.ds === states.OPEN ) interaction = "open";
      else if ( changed.ds === states.CLOSED ) {
        if ( this.#priorDoorState === states.OPEN ) interaction = "close";
        else if ( this.#priorDoorState === states.LOCKED ) interaction = "unlock";
      }
      if ( options.sound !== false ) this._playDoorSound(interaction);
      this.#priorDoorState = changed.ds;
    }

    // Incremental Refresh
    this.renderFlags.set({
      refreshLine: edgeChange || doorChange,
      refreshDirection: "dir" in changed
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    this.clearDoorControl();
    this.initializeEdge({deleted: true});
    this.#onModifyWall(false);
  }

  /* -------------------------------------------- */

  /**
   * Callback actions when a wall that contains a door is moved or its state is changed
   * @param {boolean} doorChange   Update vision and sound restrictions
   */
  #onModifyWall(doorChange=false) {
    canvas.perception.update({
      refreshEdges: true,         // Recompute edge intersections
      initializeLighting: true,   // Recompute light sources
      initializeVision: true,     // Recompute vision sources
      initializeSounds: true      // Recompute sound sources
    });

    // Re-draw door icons
    if ( doorChange ) {
      const dt = this.document.door;
      const hasCtrl = (dt === CONST.WALL_DOOR_TYPES.DOOR) || ((dt === CONST.WALL_DOOR_TYPES.SECRET) && game.user.isGM);
      if ( hasCtrl ) {
        if ( this.doorControl ) this.doorControl.draw(); // Asynchronous
        else this.createDoorControl();
      }
      else this.clearDoorControl();
    }
    else if ( this.doorControl ) this.doorControl.reposition();
  }

  /* -------------------------------------------- */

  /**
   * Play a door interaction sound.
   * This plays locally, each client independently applies this workflow.
   * @param {string} interaction      The door interaction: "open", "close", "lock", "unlock", or "test".
   * @protected
   * @internal
   */
  _playDoorSound(interaction) {
    if ( !CONST.WALL_DOOR_INTERACTIONS.includes(interaction) ) {
      throw new Error(`"${interaction}" is not a valid door interaction type`);
    }
    if ( !this.isDoor ) return;

    // Identify which door sound effect to play
    const doorSound = CONFIG.Wall.doorSounds[this.document.doorSound];
    let sounds = doorSound?.[interaction];
    if ( sounds && !Array.isArray(sounds) ) sounds = [sounds];
    else if ( !sounds?.length ) {
      if ( interaction !== "test" ) return;
      sounds = [CONFIG.sounds.lock];
    }
    const src = sounds[Math.floor(Math.random() * sounds.length)];

    // Play the door sound as a localized sound effect
    canvas.sounds.playAtPosition(src, this.center, this.soundRadius, {
      volume: 1.0,
      easing: true,
      walls: false,
      gmAlways: true,
      muffledEffect: {type: "lowpass", intensity: 5}
    });
  }

  /* -------------------------------------------- */

  /**
   * Customize the audible radius of sounds emitted by this wall, for example when a door opens or closes.
   * @type {number}
   */
  get soundRadius() {
    return canvas.dimensions.distance * 12; // 60 feet on a 5ft grid
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _canControl(user, event) {
    if ( !this.layer.active || this.isPreview ) return false;
    // If the User is chaining walls, we don't want to control the last one
    const isChain = this.hover && (game.keyboard.downKeys.size === 1)
      && game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.CONTROL);
    return !isChain;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onHoverIn(event, options) {
    // contrary to hover out, hover in is prevented in chain mode to avoid distracting the user
    if ( this.layer._chain ) return false;
    const dest = event.getLocalPosition(this.layer);
    this.layer.last = {
      point: WallsLayer.getClosestEndpoint(dest, this)
    };
    return super._onHoverIn(event, options);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onHoverOut(event) {
    const mgr = canvas.mouseInteractionManager;
    if ( this.hover && !this.layer._chain && (mgr.state < mgr.states.CLICKED) ) this.layer.last = {point: null};
    return super._onHoverOut(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _overlapsSelection(rectangle) {
    const [ax, ay, bx, by] = this.document.c;
    const {left, right, top, bottom} = rectangle;
    let tmin = -Infinity;
    let tmax = Infinity;
    const dx = bx - ax;
    if ( dx !== 0 ) {
      const tx1 = (left - ax) / dx;
      const tx2 = (right - ax) / dx;
      tmin = Math.max(tmin, Math.min(tx1, tx2));
      tmax = Math.min(tmax, Math.max(tx1, tx2));
    }
    else if ( (ax < left) || (ax > right) ) return false;
    const dy = by - ay;
    if ( dy !== 0 ) {
      const ty1 = (top - ay) / dy;
      const ty2 = (bottom - ay) / dy;
      tmin = Math.max(tmin, Math.min(ty1, ty2));
      tmax = Math.min(tmax, Math.max(ty1, ty2));
    }
    else if ( (ay < top) || (ay > bottom) ) return false;
    if ( (tmin > 1) || (tmax < 0) || (tmax < tmin) ) return false;
    return true;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onClickLeft(event) {
    if ( this.layer._chain ) return false;
    event.stopPropagation();
    const alt = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.ALT);
    const shift = game.keyboard.isModifierActive(KeyboardManager.MODIFIER_KEYS.SHIFT);
    if ( this.controlled && !alt ) {
      if ( shift ) return this.release();
      else if ( this.layer.controlled.length > 1 ) return this.layer._onDragLeftStart(event);
    }
    return this.control({releaseOthers: !shift, chain: alt});
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickLeft2(event) {
    event.stopPropagation();
    const sheet = this.sheet;
    sheet.render(true, {walls: this.layer.controlled});
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickRight2(event) {
    event.stopPropagation();
    const sheet = this.sheet;
    sheet.render(true, {walls: this.layer.controlled});
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragLeftStart(event) {
    const origin = event.interactionData.origin;
    const dLeft = Math.hypot(origin.x - this.coords[0], origin.y - this.coords[1]);
    const dRight = Math.hypot(origin.x - this.coords[2], origin.y - this.coords[3]);
    event.interactionData.fixed = dLeft < dRight ? 1 : 0; // Affix the opposite point
    return super._onDragLeftStart(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragLeftMove(event) {
    // Pan the canvas if the drag event approaches the edge
    canvas._onDragCanvasPan(event);

    // Group movement
    const {destination, fixed, origin} = event.interactionData;
    let clones = event.interactionData.clones || [];
    const snap = !event.shiftKey;

    if ( clones.length > 1 ) {
      // Drag a group of walls - snap to the end point maintaining relative positioning
      const p0 = fixed ? this.coords.slice(0, 2) : this.coords.slice(2, 4);
      // Get the snapped final point
      const pt = this.layer._getWallEndpointCoordinates({
        x: destination.x + (p0[0] - origin.x),
        y: destination.y + (p0[1] - origin.y)
      }, {snap});
      const dx = pt[0] - p0[0];
      const dy = pt[1] - p0[1];
      for ( let c of clones ) {
        c.document.c = c._original.document.c.map((p, i) => i % 2 ? p + dy : p + dx);
      }
    }

    // Single-wall pivot
    else if ( clones.length === 1 ) {
      const w = clones[0];
      const pt = this.layer._getWallEndpointCoordinates(destination, {snap});
      w.document.c = fixed ? pt.concat(this.coords.slice(2, 4)) : this.coords.slice(0, 2).concat(pt);
    }

    // Refresh display
    clones.forEach(c => c.renderFlags.set({refreshLine: true}));
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareDragLeftDropUpdates(event) {
    const {clones, destination, fixed, origin} = event.interactionData;
    const snap = !event.shiftKey;
    const updates = [];

    // Pivot a single wall
    if ( clones.length === 1 ) {
      // Get the snapped final point
      const pt = this.layer._getWallEndpointCoordinates(destination, {snap});
      const p0 = fixed ? this.coords.slice(2, 4) : this.coords.slice(0, 2);
      const coords = fixed ? pt.concat(p0) : p0.concat(pt);

      // If we collapsed the wall, delete it
      if ( (coords[0] === coords[2]) && (coords[1] === coords[3]) ) {
        this.document.delete().finally(() => this.layer.clearPreviewContainer());
        return null; // No further updates
      }

      // Otherwise shift the last point
      this.layer.last.point = pt;
      updates.push({_id: clones[0]._original.id, c: coords});
      return updates;
    }

    // Drag a group of walls - snap to the end point maintaining relative positioning
    const p0 = fixed ? this.coords.slice(0, 2) : this.coords.slice(2, 4);
    const pt = this.layer._getWallEndpointCoordinates({
      x: destination.x + (p0[0] - origin.x),
      y: destination.y + (p0[1] - origin.y)
    }, {snap});
    const dx = pt[0] - p0[0];
    const dy = pt[1] - p0[1];
    for ( const clone of clones ) {
      const c = clone._original.document.c;
      updates.push({_id: clone._original.id, c: [c[0]+dx, c[1]+dy, c[2]+dx, c[3]+dy]});
    }
    return updates;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get roof() {
    foundry.utils.logCompatibilityWarning("Wall#roof has been deprecated. There's no replacement", {since: 12, until: 14});
    return null;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get hasActiveRoof() {
    foundry.utils.logCompatibilityWarning("Wall#hasActiveRoof has been deprecated. There's no replacement", {since: 12, until: 14});
    return false;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  identifyInteriorState() {
    foundry.utils.logCompatibilityWarning("Wall#identifyInteriorState has been deprecated. "
      + "It has no effect anymore and there's no replacement.", {since: 12, until: 14});
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  orientPoint(point) {
    foundry.utils.logCompatibilityWarning("Wall#orientPoint has been moved to foundry.canvas.edges.Edge#orientPoint",
      {since: 12, until: 14});
    return this.edge.orientPoint(point);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  applyThreshold(sourceType, sourceOrigin, externalRadius=0) {
    foundry.utils.logCompatibilityWarning("Wall#applyThreshold has been moved to"
      + " foundry.canvas.edges.Edge#applyThreshold", {since: 12, until: 14});
    return this.edge.applyThreshold(sourceType, sourceOrigin, externalRadius);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get vertices() {
    foundry.utils.logCompatibilityWarning("Wall#vertices is replaced by Wall#edge", {since: 12, until: 14});
    return this.#edge;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get A() {
    foundry.utils.logCompatibilityWarning("Wall#A is replaced by Wall#edge#a", {since: 12, until: 14});
    return this.#edge.a;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get B() {
    foundry.utils.logCompatibilityWarning("Wall#A is replaced by Wall#edge#b", {since: 12, until: 14});
    return this.#edge.b;
  }
}


/**
 * Wrapper for a web worker meant to convert a pixel buffer to the specified image format
 * and quality and return a base64 image
 * @param {string} name                            The worker name to be initialized
 * @param {object} [config={}]                     Worker initialization options
 * @param {boolean} [config.debug=false]           Should the worker run in debug mode?
 */
class TextureCompressor extends AsyncWorker {
  constructor(name="Texture Compressor", config={}) {
    config.debug ??= false;
    config.scripts ??= ["./workers/image-compressor.js", "./spark-md5.min.js"];
    config.loadPrimitives ??= false;
    super(name, config);

    // Do we need to control the hash?
    this.#controlHash = config.controlHash ?? false;
  }

  /**
   * Boolean to know if the texture compressor should control the hash.
   * @type {boolean}
   */
  #controlHash;

  /**
   * Previous texture hash.
   * @type {string}
   */
  #textureHash = "";

  /* -------------------------------------------- */

  /**
   * Process the non-blocking image compression to a base64 string.
   * @param {Uint8ClampedArray} buffer                      Buffer used to create the image data.
   * @param {number} width                                  Buffered image width.
   * @param {number} height                                 Buffered image height.
   * @param {object} options
   * @param {string} [options.type="image/png"]             The required image type.
   * @param {number} [options.quality=1]                    The required image quality.
   * @param {boolean} [options.debug]                       The debug option.
   * @returns {Promise<*>}
   */
  async compressBufferBase64(buffer, width, height, options={}) {
    if ( this.#controlHash ) options.hash = this.#textureHash;
    const params = {buffer, width, height, ...options};
    const result = await this.executeFunction("processBufferToBase64", [params], [buffer.buffer]);
    if ( result.hash ) this.#textureHash = result.hash;
    return result;
  }

  /* -------------------------------------------- */

  /**
   * Expand a buffer in RED format to a buffer in RGBA format.
   * @param {Uint8ClampedArray} buffer                      Buffer used to create the image data.
   * @param {number} width                                  Buffered image width.
   * @param {number} height                                 Buffered image height.
   * @param {object} options
   * @param {boolean} [options.debug]                       The debug option.
   * @returns {Promise<*>}
   */
  async expandBufferRedToBufferRGBA(buffer, width, height, options={}) {
    if ( this.#controlHash ) options.hash = this.#textureHash;
    const params = {buffer, width, height, ...options};
    const result = await this.executeFunction("processBufferRedToBufferRGBA", [params], [buffer.buffer]);
    if ( result.hash ) this.#textureHash = result.hash;
    return result;
  }

  /* -------------------------------------------- */

  /**
   * Reduce a buffer in RGBA format to a buffer in RED format.
   * @param {Uint8ClampedArray} buffer                      Buffer used to create the image data.
   * @param {number} width                                  Buffered image width.
   * @param {number} height                                 Buffered image height.
   * @param {object} options
   * @param {boolean} [options.debug]                       The debug option.
   * @returns {Promise<*>}
   */
  async reduceBufferRGBAToBufferRED(buffer, width, height, options={}) {
    if ( this.#controlHash ) options.hash = this.#textureHash;
    const params = {buffer, width, height, ...options};
    const result = await this.executeFunction("processBufferRGBAToBufferRED", [params], [buffer.buffer]);
    if ( result.hash ) this.#textureHash = result.hash;
    return result;
  }
}

/**
 * A mixin which decorates a DisplayObject with additional properties expected for rendering in the PrimaryCanvasGroup.
 * @category - Mixins
 * @param {typeof PIXI.DisplayObject} DisplayObject   The parent DisplayObject class being mixed
 * @returns {typeof PrimaryCanvasObject}              A DisplayObject subclass mixed with PrimaryCanvasObject features
 * @mixin
 */
function PrimaryCanvasObjectMixin(DisplayObject) {

  /**
   * A display object rendered in the PrimaryCanvasGroup.
   * @param {...*} args    The arguments passed to the base class constructor
   */
  return class PrimaryCanvasObject extends CanvasTransformMixin(DisplayObject) {
    constructor(...args) {
      super(...args);
      // Activate culling and initialize handlers
      this.cullable = true;
      this.on("added", this._onAdded);
      this.on("removed", this._onRemoved);
    }

    /**
     * An optional reference to the object that owns this PCO.
     * This property does not affect the behavior of the PCO itself.
     * @type {*}
     * @default null
     */
    object = null;

    /**
     * The entry in the quadtree.
     * @type {QuadtreeObject|null}
     */
    #quadtreeEntry = null;

    /**
     * Update the quadtree entry?
     * @type {boolean}
     */
    #quadtreeDirty = false;

    /* -------------------------------------------- */
    /*  Properties                                  */
    /* -------------------------------------------- */

    /**
     * The elevation of this object.
     * @type {number}
     */
    get elevation() {
      return this.#elevation;
    }

    set elevation(value) {
      if ( (typeof value !== "number") || Number.isNaN(value) ) {
        throw new Error("PrimaryCanvasObject#elevation must be a numeric value.");
      }
      if ( value === this.#elevation ) return;
      this.#elevation = value;
      if ( this.parent ) {
        this.parent.sortDirty = true;
        if ( this.shouldRenderDepth ) canvas.masks.depth._elevationDirty = true;
      }
    }

    #elevation = 0;

    /* -------------------------------------------- */

    /**
     * A key which resolves ties amongst objects at the same elevation within the same layer.
     * @type {number}
     */
    get sort() {
      return this.#sort;
    }

    set sort(value) {
      if ( (typeof value !== "number") || Number.isNaN(value) ) {
        throw new Error("PrimaryCanvasObject#sort must be a numeric value.");
      }
      if ( value === this.#sort ) return;
      this.#sort = value;
      if ( this.parent ) this.parent.sortDirty = true;
    }

    #sort = 0;

    /* -------------------------------------------- */

    /**
     * A key which resolves ties amongst objects at the same elevation of different layers.
     * @type {number}
     */
    get sortLayer() {
      return this.#sortLayer;
    }

    set sortLayer(value) {
      if ( (typeof value !== "number") || Number.isNaN(value) ) {
        throw new Error("PrimaryCanvasObject#sortLayer must be a numeric value.");
      }
      if ( value === this.#sortLayer ) return;
      this.#sortLayer = value;
      if ( this.parent ) this.parent.sortDirty = true;
    }

    #sortLayer = 0;

    /* -------------------------------------------- */

    /**
     * A key which resolves ties amongst objects at the same elevation within the same layer and same sort.
     * @type {number}
     */
    get zIndex() {
      return this._zIndex;
    }

    set zIndex(value) {
      if ( (typeof value !== "number") || Number.isNaN(value) ) {
        throw new Error("PrimaryCanvasObject#zIndex must be a numeric value.");
      }
      if ( value === this._zIndex ) return;
      this._zIndex = value;
      if ( this.parent ) this.parent.sortDirty = true;
    }

    /* -------------------------------------------- */
    /*  PIXI Events                                 */
    /* -------------------------------------------- */

    /**
     * Event fired when this display object is added to a parent.
     * @param {PIXI.Container} parent   The new parent container.
     * @protected
     */
    _onAdded(parent) {
      if ( parent !== canvas.primary ) {
        throw new Error("PrimaryCanvasObject instances may only be direct children of the PrimaryCanvasGroup");
      }
    }

    /* -------------------------------------------- */

    /**
     * Event fired when this display object is removed from its parent.
     * @param {PIXI.Container} parent   Parent from which the PCO is removed.
     * @protected
     */
    _onRemoved(parent) {
      this.#updateQuadtree(true);
    }

    /* -------------------------------------------- */
    /*  Canvas Transform & Quadtree                 */
    /* -------------------------------------------- */

    /** @inheritdoc */
    updateCanvasTransform() {
      super.updateCanvasTransform();
      this.#updateQuadtree();
      this.#updateDepth();
    }

    /* -------------------------------------------- */

    /** @inheritdoc */
    _onCanvasBoundsUpdate() {
      super._onCanvasBoundsUpdate();
      this.#quadtreeDirty = true;
    }

    /* -------------------------------------------- */

    /**
     * Update the quadtree.
     * @param {boolean} [remove=false]    Remove the quadtree entry?
     */
    #updateQuadtree(remove=false) {
      if ( !this.#quadtreeDirty && !remove ) return;
      this.#quadtreeDirty = false;
      if ( !remove && (this.canvasBounds.width > 0) && (this.canvasBounds.height > 0) ) {
        this.#quadtreeEntry ??= {r: this.canvasBounds, t: this};
        canvas.primary.quadtree.update(this.#quadtreeEntry);
      } else if ( this.#quadtreeEntry ) {
        this.#quadtreeEntry = null;
        canvas.primary.quadtree.remove(this);
      }
    }

    /* -------------------------------------------- */
    /*  PCO Properties                              */
    /* -------------------------------------------- */

    /**
     * Does this object render to the depth buffer?
     * @type {boolean}
     */
    get shouldRenderDepth() {
      return this.#shouldRenderDepth;
    }

    /** @type {boolean} */
    #shouldRenderDepth = false;

    /* -------------------------------------------- */
    /*  Depth Rendering                             */
    /* -------------------------------------------- */

    /**
     * Flag the depth as dirty if necessary.
     */
    #updateDepth() {
      const shouldRenderDepth = this._shouldRenderDepth();
      if ( this.#shouldRenderDepth === shouldRenderDepth ) return;
      this.#shouldRenderDepth = shouldRenderDepth;
      canvas.masks.depth._elevationDirty = true;
    }

    /* -------------------------------------------- */

    /**
     * Does this object render to the depth buffer?
     * @returns {boolean}
     * @protected
     */
    _shouldRenderDepth() {
      return false;
    }

    /* -------------------------------------------- */

    /**
     * Render the depth of this object.
     * @param {PIXI.Renderer} renderer
     */
    renderDepthData(renderer) {}

    /* -------------------------------------------- */
    /*  Deprecations and Compatibility              */
    /* -------------------------------------------- */

    /**
     * @deprecated since v11
     * @ignore
     */
    renderOcclusion(renderer) {
      const msg = "PrimaryCanvasObject#renderOcclusion is deprecated in favor of PrimaryCanvasObject#renderDepthData";
      foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
      this.renderDepthData(renderer);
    }

    /* -------------------------------------------- */

    /**
     * @deprecated since v12
     * @ignore
     */
    get document() {
      foundry.utils.logCompatibilityWarning("PrimaryCanvasObject#document is deprecated.", {since: 12, until: 14});
      if ( !(this.object instanceof PlaceableObject) ) return null;
      return this.object.document || null;
    }

    /* -------------------------------------------- */

    /**
     * @deprecated since v12
     * @ignore
     */
    updateBounds() {
      const msg = "PrimaryCanvasObject#updateBounds is deprecated and has no effect.";
      foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    }
  };
}

/**
 * A mixin which decorates a DisplayObject with additional properties for canvas transforms and bounds.
 * @category - Mixins
 * @param {typeof PIXI.Container} DisplayObject   The parent DisplayObject class being mixed
 * @returns {typeof CanvasTransformMixin}         A DisplayObject subclass mixed with CanvasTransformMixin features
 * @mixin
 */
function CanvasTransformMixin(DisplayObject) {
  return class CanvasTransformMixin extends DisplayObject {
    constructor(...args) {
      super(...args);
      this.on("added", this.#resetCanvasTransformParentID);
      this.on("removed", this.#resetCanvasTransformParentID);
    }

    /* -------------------------------------------- */
    /*  Properties                                  */
    /* -------------------------------------------- */

    /**
     * The transform matrix from local space to canvas space.
     * @type {PIXI.Matrix}
     */
    canvasTransform = new PIXI.Matrix();

    /* -------------------------------------------- */

    /**
     * The update ID of canvas transform matrix.
     * @type {number}
     * @internal
     */
    _canvasTransformID = -1;

    /* -------------------------------------------- */

    /**
     * The update ID of the local transform of this object.
     * @type {number}
     */
    #canvasTransformLocalID = -1;

    /* -------------------------------------------- */

    /**
     * The update ID of the canvas transform of the parent.
     * @type {number}
     */
    #canvasTransformParentID = -1;

    /* -------------------------------------------- */

    /**
     * The canvas bounds of this object.
     * @type {PIXI.Rectangle}
     */
    canvasBounds = new PIXI.Rectangle();

    /* -------------------------------------------- */

    /**
     * The canvas bounds of this object.
     * @type {PIXI.Bounds}
     * @protected
     */
    _canvasBounds = new PIXI.Bounds();

    /* -------------------------------------------- */

    /**
     * The update ID of the canvas bounds.
     * Increment to force recalculation.
     * @type {number}
     * @protected
     */
    _canvasBoundsID = 0;

    /* -------------------------------------------- */

    /**
     * Reset the parent ID of the canvas transform.
     */
    #resetCanvasTransformParentID() {
      this.#canvasTransformParentID = -1;
    }

    /* -------------------------------------------- */
    /*  Methods                                     */
    /* -------------------------------------------- */

    /**
     * Calculate the canvas bounds of this object.
     * @protected
     */
    _calculateCanvasBounds() {}

    /* -------------------------------------------- */

    /**
     * Recalculate the canvas transform and bounds of this object and its children, if necessary.
     */
    updateCanvasTransform() {
      this.transform.updateLocalTransform();

      // If the local transform or the parent canvas transform has changed,
      // recalculate the canvas transform of this object
      if ( (this.#canvasTransformLocalID !== this.transform._localID)
        || (this.#canvasTransformParentID !== (this.parent._canvasTransformID ?? 0)) ) {
        this.#canvasTransformLocalID = this.transform._localID;
        this.#canvasTransformParentID = this.parent._canvasTransformID ?? 0;
        this._canvasTransformID++;
        this.canvasTransform.copyFrom(this.transform.localTransform);

        // Prepend the parent canvas transform matrix (if exists)
        if ( this.parent.canvasTransform ) this.canvasTransform.prepend(this.parent.canvasTransform);
        this._canvasBoundsID++;
        this._onCanvasTransformUpdate();
      }

      // Recalculate the canvas bounds of this object if necessary
      if ( this._canvasBounds.updateID !== this._canvasBoundsID ) {
        this._canvasBounds.updateID = this._canvasBoundsID;
        this._canvasBounds.clear();
        this._calculateCanvasBounds();

        // Set the width and height of the canvas bounds rectangle to 0
        // if the bounds are empty. PIXI.Bounds#getRectangle does not
        // change the rectangle passed to it if the bounds are empty:
        // so we need to handle the empty case here.
        if ( this._canvasBounds.isEmpty() ) {
          this.canvasBounds.x = this.x;
          this.canvasBounds.y = this.y;
          this.canvasBounds.width = 0;
          this.canvasBounds.height = 0;
        }

        // Update the canvas bounds rectangle
        else this._canvasBounds.getRectangle(this.canvasBounds);
        this._onCanvasBoundsUpdate();
      }

      // Recursively update child canvas transforms
      const children = this.children;
      for ( let i = 0, n = children.length; i < n; i++ ) {
        children[i].updateCanvasTransform?.();
      }
    }

    /* -------------------------------------------- */

    /**
     * Called when the canvas transform changed.
     * @protected
     */
    _onCanvasTransformUpdate() {}

    /* -------------------------------------------- */

    /**
     * Called when the canvas bounds changed.
     * @protected
     */
    _onCanvasBoundsUpdate() {}

    /* -------------------------------------------- */

    /**
     * Is the given point in canvas space contained in this object?
     * @param {PIXI.IPointData} point    The point in canvas space.
     * @returns {boolean}
     */
    containsCanvasPoint(point) {
      return false;
    }
  };
}

/**
 * A basic PCO which is handling drawings of any shape.
 * @extends {PIXI.Graphics}
 * @mixes PrimaryCanvasObject
 *
 * @param {object} [options]                               A config object
 * @param {PIXI.GraphicsGeometry} [options.geometry]       A geometry passed to the graphics.
 * @param {string|null} [options.name]                     The name of the PCO.
 * @param {*} [options.object]                             Any object that owns this PCO.
 */
class PrimaryGraphics extends PrimaryCanvasObjectMixin(PIXI.Graphics) {
  constructor(options) {
    let geometry;
    if ( options instanceof PIXI.GraphicsGeometry ) {
      geometry = options;
      options = {};
    } else if ( options instanceof Object ) {
      geometry = options.geometry;
    } else {
      options = {};
    }
    super(geometry);
    this.name = options.name ?? null;
    this.object = options.object ?? null;
  }

  /* -------------------------------------------- */

  /**
   * A temporary point used by this class.
   * @type {PIXI.Point}
   */
  static #TEMP_POINT = new PIXI.Point();

  /* -------------------------------------------- */

  /**
   * The dirty ID of the geometry.
   * @type {number}
   */
  #geometryDirty = -1;

  /* -------------------------------------------- */

  /**
   * Does the geometry contain points?
   * @type {boolean}
   */
  #geometryContainsPoints = false;

  /* -------------------------------------------- */

  /** @override */
  _calculateCanvasBounds() {
    this.finishPoly();
    const geometry = this._geometry;
    if ( !geometry.graphicsData.length ) return;
    const { minX, minY, maxX, maxY } = geometry.bounds;
    this._canvasBounds.addFrameMatrix(this.canvasTransform, minX, minY, maxX, maxY);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  updateCanvasTransform() {
    if ( this.#geometryDirty !== this._geometry.dirty ) {
      this.#geometryDirty = this._geometry.dirty;
      this.#geometryContainsPoints = false;
      const graphicsData = this._geometry.graphicsData;
      for ( let i = 0; i < graphicsData.length; i++ ) {
        const data = graphicsData[i];
        if ( data.shape && data.fillStyle.visible ) {
          this.#geometryContainsPoints = true;
          break;
        }
      }
      this._canvasBoundsID++;
    }
    super.updateCanvasTransform();
  }

  /* -------------------------------------------- */

  /** @override */
  containsCanvasPoint(point) {
    if ( !this.#geometryContainsPoints ) return false;
    if ( !this.canvasBounds.contains(point.x, point.y) ) return false;
    point = this.canvasTransform.applyInverse(point, PrimaryGraphics.#TEMP_POINT);
    return this._geometry.containsPoint(point);
  }
}



/**
 * A mixin which decorates a DisplayObject with depth and/or occlusion properties.
 * @category - Mixins
 * @param {typeof PIXI.DisplayObject} DisplayObject   The parent DisplayObject class being mixed
 * @returns {typeof PrimaryOccludableObject}          A DisplayObject subclass mixed with OccludableObject features
 * @mixin
 */
function PrimaryOccludableObjectMixin(DisplayObject) {
  class PrimaryOccludableObject extends PrimaryCanvasObjectMixin(DisplayObject) {

    /**
     * Restrictions options packed into a single value with bitwise logic.
     * @type {foundry.utils.BitMask}
     */
    #restrictionState = new foundry.utils.BitMask({
      light: false,
      weather: false
    });

    /**
     * Is this occludable object hidden for Gamemaster visibility only?
     * @type {boolean}
     */
    hidden = false;

    /**
     * A flag which tracks whether the primary canvas object is currently in an occluded state.
     * @type {boolean}
     */
    occluded = false;

    /**
     * The occlusion mode of this occludable object.
     * @type {number}
     */
    occlusionMode = CONST.OCCLUSION_MODES.NONE;

    /**
     * The unoccluded alpha of this object.
     * @type {number}
     */
    unoccludedAlpha = 1;

    /**
     * The occlusion alpha of this object.
     * @type {number}
     */
    occludedAlpha = 0;

    /**
     * Fade this object on hover?
     * @type {boolean}
     * @defaultValue true
     */
    get hoverFade() {
      return this.#hoverFade;
    }

    set hoverFade(value) {
      if ( this.#hoverFade === value ) return;
      this.#hoverFade = value;
      const state = this._hoverFadeState;
      state.hovered = false;
      state.faded = false;
      state.fading = false;
      state.occlusion = 0;
    }

    /**
     * Fade this object on hover?
     * @type {boolean}
     */
    #hoverFade = true;

    /**
     * @typedef {object} OcclusionState
     * @property {number} fade            The amount of FADE occlusion
     * @property {number} radial          The amount of RADIAL occlusion
     * @property {number} vision          The amount of VISION occlusion
     */

    /**
     * The amount of rendered FADE, RADIAL, and VISION occlusion.
     * @type {OcclusionState}
     * @internal
     */
    _occlusionState = {
      fade: 0.0,
      radial: 0.0,
      vision: 0.0
    };

    /**
     * @typedef {object} HoverFadeState
     * @property {boolean} hovered        The hovered state
     * @property {number} hoveredTime     The last time when a mouse event was hovering this object
     * @property {boolean} faded          The faded state
     * @property {boolean} fading         The fading state
     * @property {number} fadingTime      The time the fade animation started
     * @property {number} occlusion       The amount of occlusion
     */

    /**
     * The state of hover-fading.
     * @type {HoverFadeState}
     * @internal
     */
    _hoverFadeState = {
      hovered: false,
      hoveredTime: 0,
      faded: false,
      fading: false,
      fadingTime: 0,
      occlusion: 0.0
    };

    /* -------------------------------------------- */
    /*  Properties                                  */
    /* -------------------------------------------- */

    /**
     * Get the blocking option bitmask value.
     * @returns {number}
     * @internal
     */
    get _restrictionState() {
      return this.#restrictionState.valueOf();
    }

    /* -------------------------------------------- */

    /**
     * Is this object blocking light?
     * @type {boolean}
     */
    get restrictsLight() {
      return this.#restrictionState.hasState(this.#restrictionState.states.light);
    }

    set restrictsLight(enabled) {
      this.#restrictionState.toggleState(this.#restrictionState.states.light, enabled);
    }

    /* -------------------------------------------- */

    /**
     * Is this object blocking weather?
     * @type {boolean}
     */
    get restrictsWeather() {
      return this.#restrictionState.hasState(this.#restrictionState.states.weather);
    }

    set restrictsWeather(enabled) {
      this.#restrictionState.toggleState(this.#restrictionState.states.weather, enabled);
    }

    /* -------------------------------------------- */

    /**
     * Is this occludable object... occludable?
     * @type {boolean}
     */
    get isOccludable() {
      return this.occlusionMode > CONST.OCCLUSION_MODES.NONE;
    }

    /* -------------------------------------------- */

    /**
     * Debounce assignment of the PCO occluded state to avoid cases like animated token movement which can rapidly
     * change PCO appearance.
     * Uses a 50ms debounce threshold.
     * Objects which are in the hovered state remain occluded until their hovered state ends.
     * @type {function(occluded: boolean): void}
     */
    debounceSetOcclusion = foundry.utils.debounce(occluded => this.occluded = occluded, 50);

    /* -------------------------------------------- */

    /** @inheritDoc */
    updateCanvasTransform() {
      super.updateCanvasTransform();
      this.#updateHoverFadeState();
      this.#updateOcclusionState();
    }

    /* -------------------------------------------- */
    /*  Methods                                     */
    /* -------------------------------------------- */

    /**
     * Update the occlusion state.
     */
    #updateOcclusionState() {
      const state = this._occlusionState;
      state.fade = 0;
      state.radial = 0;
      state.vision = 0;
      const M = CONST.OCCLUSION_MODES;
      switch ( this.occlusionMode ) {
        case M.FADE: if ( this.occluded ) state.fade = 1; break;
        case M.RADIAL: state.radial = 1; break;
        case M.VISION:
          if ( canvas.masks.occlusion.vision ) state.vision = 1;
          else if ( this.occluded ) state.fade = 1;
          break;
      }
      const hoverFade = this._hoverFadeState.occlusion;
      if ( canvas.masks.occlusion.vision ) state.vision = Math.max(state.vision, hoverFade);
      else state.fade = Math.max(state.fade, hoverFade);
    }

    /* -------------------------------------------- */

    /**
     * Update the hover-fade state.
     */
    #updateHoverFadeState() {
      if ( !this.#hoverFade ) return;
      const state = this._hoverFadeState;
      const time = canvas.app.ticker.lastTime;
      const {delay, duration} = CONFIG.Canvas.hoverFade;
      if ( state.fading ) {
        const dt = time - state.fadingTime;
        if ( dt >= duration ) state.fading = false;
      } else if ( state.faded !== state.hovered ) {
        const dt = time - state.hoveredTime;
        if ( dt >= delay ) {
          state.faded = state.hovered;
          if ( dt - delay < duration ) {
            state.fading = true;
            state.fadingTime = time;
          }
        }
      }
      let occlusion = 1;
      if ( state.fading ) {
        if ( state.faded !== state.hovered ) {
          state.faded = state.hovered;
          state.fadingTime = time - (state.fadingTime + duration - time);
        }
        occlusion = CanvasAnimation.easeInOutCosine((time - state.fadingTime) / duration);
      }
      state.occlusion = state.faded ? occlusion : 1 - occlusion;
    }

    /* -------------------------------------------- */
    /*  Depth Rendering                             */
    /* -------------------------------------------- */

    /** @override */
    _shouldRenderDepth() {
      return !this.#restrictionState.isEmpty && !this.hidden;
    }

    /* -------------------------------------------- */

    /**
     * Test whether a specific Token occludes this PCO.
     * Occlusion is tested against 9 points, the center, the four corners-, and the four cardinal directions
     * @param {Token} token       The Token to test
     * @param {object} [options]  Additional options that affect testing
     * @param {boolean} [options.corners=true]  Test corners of the hit-box in addition to the token center?
     * @returns {boolean}         Is the Token occluded by the PCO?
     */
    testOcclusion(token, {corners=true}={}) {
      if ( token.document.elevation >= this.elevation ) return false;
      const {x, y, w, h} = token;
      let testPoints = [[w / 2, h / 2]];
      if ( corners ) {
        const pad = 2;
        const cornerPoints = [
          [pad, pad],
          [w / 2, pad],
          [w - pad, pad],
          [w - pad, h / 2],
          [w - pad, h - pad],
          [w / 2, h - pad],
          [pad, h - pad],
          [pad, h / 2]
        ];
        testPoints = testPoints.concat(cornerPoints);
      }
      for ( const [tx, ty] of testPoints ) {
        if ( this.containsCanvasPoint({x: x + tx, y: y + ty}) ) return true;
      }
      return false;
    }

    /* -------------------------------------------- */
    /*  Deprecations and Compatibility              */
    /* -------------------------------------------- */

    /**
     * @deprecated since v12
     * @ignore
     */
    get roof() {
      const msg = `${this.constructor.name}#roof is deprecated in favor of more granular options: 
      ${this.constructor.name}#BlocksLight and ${this.constructor.name}#BlocksWeather`;
      foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
      return this.restrictsLight && this.restrictsWeather;
    }

    /**
     * @deprecated since v12
     * @ignore
     */
    set roof(enabled) {
      const msg = `${this.constructor.name}#roof is deprecated in favor of more granular options: 
      ${this.constructor.name}#BlocksLight and ${this.constructor.name}#BlocksWeather`;
      foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
      this.restrictsWeather = enabled;
      this.restrictsLight = enabled;
    }

    /* -------------------------------------------- */

    /**
     * @deprecated since v12
     * @ignore
     */
    containsPixel(x, y, alphaThreshold=0.75) {
      const msg = `${this.constructor.name}#containsPixel is deprecated. Use ${this.constructor.name}#containsCanvasPoint instead.`;
      foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
      return this.containsCanvasPoint({x, y}, alphaThreshold + 1e-6);
    }

    /* -------------------------------------------- */

    /**
     * @deprecated since v11
     * @ignore
     */
    renderOcclusion(renderer) {
      const msg = "PrimaryCanvasObject#renderOcclusion is deprecated in favor of PrimaryCanvasObject#renderDepth";
      foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
      this.renderDepthData(renderer);
    }
  }
  return PrimaryOccludableObject;
}

/**
 * A basic PCO sprite mesh which is handling occlusion and depth.
 * @extends {SpriteMesh}
 * @mixes PrimaryOccludableObjectMixin
 * @mixes PrimaryCanvasObjectMixin
 *
 * @property {PrimaryBaseSamplerShader} shader             The shader bound to this mesh.
 *
 * @param {object} [options]                               The constructor options.
 * @param {PIXI.Texture} [options.texture]                 Texture passed to the SpriteMesh.
 * @param {typeof PrimaryBaseSamplerShader} [options.shaderClass]   The shader class used to render this sprite.
 * @param {string|null} [options.name]                     The name of this sprite.
 * @param {*} [options.object]                             Any object that owns this sprite.
 */
class PrimarySpriteMesh extends PrimaryOccludableObjectMixin(SpriteMesh) {
  constructor(options, shaderClass) {
    let texture;
    if ( options instanceof PIXI.Texture ) {
      texture = options;
      options = {};
    } else if ( options instanceof Object ) {
      texture = options.texture;
      shaderClass = options.shaderClass;
    } else {
      options = {};
    }
    shaderClass ??= PrimaryBaseSamplerShader;
    if ( !foundry.utils.isSubclass(shaderClass, PrimaryBaseSamplerShader) ) {
      throw new Error(`${shaderClass.name} in not a subclass of PrimaryBaseSamplerShader`);
    }
    super(texture, shaderClass);
    this.name = options.name ?? null;
    this.object = options.object ?? null;
  }

  /* -------------------------------------------- */

  /**
   * A temporary point used by this class.
   * @type {PIXI.Point}
   */
  static #TEMP_POINT = new PIXI.Point();

  /* -------------------------------------------- */

  /**
   * The texture alpha data.
   * @type {TextureAlphaData|null}
   * @protected
   */
  _textureAlphaData = null;

  /* -------------------------------------------- */

  /**
   * The texture alpha threshold used for point containment tests.
   * If set to a value larger than 0, the texture alpha data is
   * extracted from the texture at 25% resolution.
   * @type {number}
   */
  textureAlphaThreshold = 0;

  /* -------------------------------------------- */
  /*  PIXI Events                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onTextureUpdate() {
    super._onTextureUpdate();
    this._textureAlphaData = null;
    this._canvasBoundsID++;
  }

  /* -------------------------------------------- */
  /*  Helper Methods                              */
  /* -------------------------------------------- */

  /** @inheritdoc */
  setShaderClass(shaderClass) {
    if ( !foundry.utils.isSubclass(shaderClass, PrimaryBaseSamplerShader) ) {
      throw new Error(`${shaderClass.name} in not a subclass of PrimaryBaseSamplerShader`);
    }
    super.setShaderClass(shaderClass);
  }

  /* -------------------------------------------- */

  /**
   * An all-in-one helper method: Resizing the PCO according to desired dimensions and options.
   * This helper computes the width and height based on the following factors:
   *
   * - The ratio of texture width and base width.
   * - The ratio of texture height and base height.
   *
   * Additionally, It takes into account the desired fit options:
   *
   * - (default) "fill" computes the exact width and height ratio.
   * - "cover" takes the maximum ratio of width and height and applies it to both.
   * - "contain" takes the minimum ratio of width and height and applies it to both.
   * - "width" applies the width ratio to both width and height.
   * - "height" applies the height ratio to both width and height.
   *
   * You can also apply optional scaleX and scaleY options to both width and height. The scale is applied after fitting.
   *
   * **Important**: By using this helper, you don't need to set the height, width, and scale properties of the DisplayObject.
   *
   * **Note**: This is a helper method. Alternatively, you could assign properties as you would with a PIXI DisplayObject.
   *
   * @param {number} baseWidth             The base width used for computations.
   * @param {number} baseHeight            The base height used for computations.
   * @param {object} [options]             The options.
   * @param {"fill"|"cover"|"contain"|"width"|"height"} [options.fit="fill"]  The fit type.
   * @param {number} [options.scaleX=1]    The scale on X axis.
   * @param {number} [options.scaleY=1]    The scale on Y axis.
   */
  resize(baseWidth, baseHeight, {fit="fill", scaleX=1, scaleY=1}={}) {
    if ( !((baseWidth >= 0) && (baseHeight >= 0)) ) {
      throw new Error(`Invalid baseWidth/baseHeight passed to ${this.constructor.name}#resize.`);
    }
    const {width: textureWidth, height: textureHeight} = this._texture;
    let sx;
    let sy;
    switch ( fit ) {
      case "fill":
        sx = baseWidth / textureWidth;
        sy = baseHeight / textureHeight;
        break;
      case "cover":
        sx = sy = Math.max(baseWidth / textureWidth, baseHeight / textureHeight);
        break;
      case "contain":
        sx = sy = Math.min(baseWidth / textureWidth, baseHeight / textureHeight);
        break;
      case "width":
        sx = sy = baseWidth / textureWidth;
        break;
      case "height":
        sx = sy = baseHeight / textureHeight;
        break;
      default:
        throw new Error(`Invalid fill type passed to ${this.constructor.name}#resize (fit=${fit}).`);
    }
    sx *= scaleX;
    sy *= scaleY;
    this.scale.set(sx, sy);
    this._width = Math.abs(sx * textureWidth);
    this._height = Math.abs(sy * textureHeight);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _updateBatchData() {
    super._updateBatchData();
    const batchData = this._batchData;
    batchData.elevation = this.elevation;
    batchData.textureAlphaThreshold = this.textureAlphaThreshold;
    batchData.unoccludedAlpha = this.unoccludedAlpha;
    batchData.occludedAlpha = this.occludedAlpha;
    const occlusionState = this._occlusionState;
    batchData.fadeOcclusion = occlusionState.fade;
    batchData.radialOcclusion = occlusionState.radial;
    batchData.visionOcclusion = occlusionState.vision;
    batchData.restrictionState = this._restrictionState;
  }

  /* -------------------------------------------- */

  /** @override */
  _calculateCanvasBounds() {
    if ( !this._texture ) return;
    const {width, height} = this._texture;
    let minX = 0;
    let minY = 0;
    let maxX = width;
    let maxY = height;
    const alphaData = this._textureAlphaData;
    if ( alphaData ) {
      const scaleX = width / alphaData.width;
      const scaleY = height / alphaData.height;
      minX = alphaData.minX * scaleX;
      minY = alphaData.minY * scaleY;
      maxX = alphaData.maxX * scaleX;
      maxY = alphaData.maxY * scaleY;
    }
    let {x: anchorX, y: anchorY} = this.anchor;
    anchorX *= width;
    anchorY *= height;
    minX -= anchorX;
    minY -= anchorY;
    maxX -= anchorX;
    maxY -= anchorY;
    this._canvasBounds.addFrameMatrix(this.canvasTransform, minX, minY, maxX, maxY);
  }

  /* -------------------------------------------- */

  /**
   * Is the given point in canvas space contained in this object?
   * @param {PIXI.IPointData} point             The point in canvas space
   * @param {number} [textureAlphaThreshold]    The minimum texture alpha required for containment
   * @returns {boolean}
   */
  containsCanvasPoint(point, textureAlphaThreshold=this.textureAlphaThreshold) {
    if ( textureAlphaThreshold > 1 ) return false;
    if ( !this.canvasBounds.contains(point.x, point.y) ) return false;
    point = this.canvasTransform.applyInverse(point, PrimarySpriteMesh.#TEMP_POINT);
    return this.#containsLocalPoint(point, textureAlphaThreshold);
  }

  /* -------------------------------------------- */

  /**
   * Is the given point in world space contained in this object?
   * @param {PIXI.IPointData} point             The point in world space
   * @param {number} [textureAlphaThreshold]    The minimum texture alpha required for containment
   * @returns {boolean}
   */
  containsPoint(point, textureAlphaThreshold=this.textureAlphaThreshold) {
    if ( textureAlphaThreshold > 1 ) return false;
    point = this.worldTransform.applyInverse(point, PrimarySpriteMesh.#TEMP_POINT);
    return this.#containsLocalPoint(point, textureAlphaThreshold);
  }

  /* -------------------------------------------- */

  /**
   * Is the given point in local space contained in this object?
   * @param {PIXI.IPointData} point           The point in local space
   * @param {number} textureAlphaThreshold    The minimum texture alpha required for containment
   * @returns {boolean}
   */
  #containsLocalPoint(point, textureAlphaThreshold) {
    const {width, height} = this._texture;
    const {x: anchorX, y: anchorY} = this.anchor;
    let {x, y} = point;
    x += (width * anchorX);
    y += (height * anchorY);
    if ( textureAlphaThreshold > 0 ) return this.#getTextureAlpha(x, y) >= textureAlphaThreshold;
    return (x >= 0) && (x < width) && (y >= 0) && (y < height);
  }

  /* -------------------------------------------- */

  /**
   * Get alpha value of texture at the given texture coordinates.
   * @param {number} x    The x-coordinate
   * @param {number} y    The y-coordinate
   * @returns {number}    The alpha value (0-1)
   */
  #getTextureAlpha(x, y) {
    if ( !this._texture ) return 0;
    if ( !this._textureAlphaData ) {
      this._textureAlphaData = TextureLoader.getTextureAlphaData(this._texture, 0.25);
      this._canvasBoundsID++;
    }

    // Transform the texture coordinates
    const {width, height} = this._texture;
    const alphaData = this._textureAlphaData;
    x *= (alphaData.width / width);
    y *= (alphaData.height / height);

    // First test against the bounding box
    const {minX, minY, maxX, maxY} = alphaData;
    if ( (x < minX) || (x >= maxX) || (y < minY) || (y >= maxY) ) return 0;

    // Get the alpha at the local coordinates
    return alphaData.data[((maxX - minX) * ((y | 0) - minY)) + ((x | 0) - minX)] / 255;
  }

  /* -------------------------------------------- */
  /*  Rendering Methods                           */
  /* -------------------------------------------- */

  /** @override */
  renderDepthData(renderer) {
    if ( !this.shouldRenderDepth || !this.visible || !this.renderable ) return;
    const shader = this._shader;
    const blendMode = this.blendMode;
    this.blendMode = PIXI.BLEND_MODES.MAX_COLOR;
    this._shader = shader.depthShader;
    if ( this.cullable ) this._renderWithCulling(renderer);
    else this._render(renderer);
    this._shader = shader;
    this.blendMode = blendMode;
  }

  /* -------------------------------------------- */

  /**
   * Render the sprite with ERASE blending.
   * Note: The sprite must not have visible/renderable children.
   * @param {PIXI.Renderer} renderer    The renderer
   * @internal
   */
  _renderVoid(renderer) {
    if ( !this.visible || (this.worldAlpha <= 0) || !this.renderable ) return;

    // Delegate to PrimarySpriteMesh#renderVoidAdvanced if the sprite has filter or mask
    if ( this._mask || this.filters?.length ) this.#renderVoidAdvanced(renderer);
    else {

      // Set the blend mode to ERASE before rendering
      const originalBlendMode = this.blendMode;
      this.blendMode = PIXI.BLEND_MODES.ERASE;

      // Render the sprite but not its children
      if ( this.cullable ) this._renderWithCulling(renderer);
      else this._render(renderer);

      // Restore the original blend mode after rendering
      this.blendMode = originalBlendMode;
    }
  }

  /* -------------------------------------------- */

  /**
   * Render the sprite that has a filter or a mask with ERASE blending.
   * Note: The sprite must not have visible/renderable children.
   * @param {PIXI.Renderer} renderer    The renderer
   */
  #renderVoidAdvanced(renderer) {

    // Same code as in PIXI.Container#renderAdvanced
    const filters = this.filters;
    const mask = this._mask;
    if ( filters ) {
      this._enabledFilters ||= [];
      this._enabledFilters.length = 0;
      for ( let i = 0; i < filters.length; i++ ) {
        if ( filters[i].enabled ) this._enabledFilters.push(filters[i]);
      }
    }
    const flush = (filters && this._enabledFilters.length) || (mask && (!mask.isMaskData
        || (mask.enabled && (mask.autoDetect || mask.type !== MASK_TYPES.NONE))));
    if ( flush ) renderer.batch.flush();
    if ( filters && this._enabledFilters.length ) renderer.filter.push(this, this._enabledFilters);
    if ( mask ) renderer.mask.push(this, mask);

    // Set the blend mode to ERASE before rendering
    let filter;
    let originalBlendMode;
    const filterState = renderer.filter.defaultFilterStack.at(-1);
    if ( filterState.target === this ) {
      filter = filterState.filters.at(-1);
      originalBlendMode = filter.blendMode;
      filter.blendMode = PIXI.BLEND_MODES.ERASE;
    } else {
      originalBlendMode = this.blendMode;
      this.blendMode = PIXI.BLEND_MODES.ERASE;
    }

    // Same code as in PIXI.Container#renderAdvanced without the part that renders children
    if ( this.cullable ) this._renderWithCulling(renderer);
    else this._render(renderer);
    if ( flush ) renderer.batch.flush();
    if ( mask ) renderer.mask.pop(this);
    if ( filters && this._enabledFilters.length ) renderer.filter.pop();

    // Restore the original blend mode after rendering
    if ( filter ) filter.blendMode = originalBlendMode;
    else this.blendMode = originalBlendMode;
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  getPixelAlpha(x, y) {
    const msg = `${this.constructor.name}#getPixelAlpha is deprecated without replacement.`;
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    if ( !this._textureAlphaData ) return null;
    if ( !this.canvasBounds.contains(x, y) ) return -1;
    const point = PrimarySpriteMesh.#TEMP_POINT.set(x, y);
    this.canvasTransform.applyInverse(point, point);
    const {width, height} = this._texture;
    const {x: anchorX, y: anchorY} = this.anchor;
    x = point.x + (width * anchorX);
    y = point.y + (height * anchorY);
    return this.#getTextureAlpha(x, y) * 255;
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  _getAlphaBounds() {
    const msg = `${this.constructor.name}#_getAlphaBounds is deprecated without replacement.`;
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    const m = this._textureAlphaData;
    const r = this.rotation;
    return PIXI.Rectangle.fromRotation(m.minX, m.minY, m.maxX - m.minX, m.maxY - m.minY, r).normalize();
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  _getTextureCoordinate(testX, testY) {
    const msg = `${this.constructor.name}#_getTextureCoordinate is deprecated without replacement.`;
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    const point = {x: testX, y: testY};
    let {x, y} = this.canvasTransform.applyInverse(point, point);
    point.x = ((x / this._texture.width) + this.anchor.x) * this._textureAlphaData.width;
    point.y = ((y / this._texture.height) + this.anchor.y) * this._textureAlphaData.height;
    return point;
  }
}


/**
 * Provide the necessary methods to get a snapshot of the framebuffer into a render texture.
 * Class meant to be used as a singleton.
 * Created with the precious advices of dev7355608.
 */
class FramebufferSnapshot {
  constructor() {
    /**
     * The RenderTexture that is the render destination for the framebuffer snapshot.
     * @type {PIXI.RenderTexture}
     */
    this.framebufferTexture = FramebufferSnapshot.#createRenderTexture();

    // Listen for resize events
    canvas.app.renderer.on("resize", () => this.#hasResized = true);
  }

  /**
   * To know if we need to update the texture.
   * @type {boolean}
   */
  #hasResized = true;

  /**
   * A placeholder for temporary copy.
   * @type {PIXI.Rectangle}
   */
  #tempSourceFrame = new PIXI.Rectangle();

  /* ---------------------------------------- */

  /**
   * Get the framebuffer texture snapshot.
   * @param {PIXI.Renderer} renderer    The renderer for this context.
   * @returns {PIXI.RenderTexture}      The framebuffer snapshot.
   */
  getFramebufferTexture(renderer) {
    // Need resize?
    if ( this.#hasResized ) {
      CachedContainer.resizeRenderTexture(renderer, this.framebufferTexture);
      this.#hasResized = false;
    }

    // Flush batched operations before anything else
    renderer.batch.flush();

    const fb = renderer.framebuffer.current;
    const vf = this.#tempSourceFrame.copyFrom(renderer.renderTexture.viewportFrame);

    // Inverted Y in the case of canvas
    if ( !fb ) vf.y = renderer.view.height - (vf.y + vf.height);

    // Empty viewport
    if ( !(vf.width > 0 && vf.height > 0) ) return PIXI.Texture.WHITE;

    // Computing bounds of the source
    let srcX = vf.x;
    let srcY = vf.y;
    let srcX2 = srcX + vf.width;
    let srcY2 = srcY + vf.height;

    // Inverted Y in the case of canvas
    if ( !fb ) {
      srcY = renderer.view.height - 1 - srcY;
      srcY2 = srcY - vf.height;
    }

    // Computing bounds of the destination
    let dstX = 0;
    let dstY = 0;
    let dstX2 = vf.width;
    let dstY2 = vf.height;

    // Preparing the gl context
    const gl = renderer.gl;
    const framebufferSys = renderer.framebuffer;
    const currentFramebuffer = framebufferSys.current;

    // Binding our render texture to the framebuffer
    framebufferSys.bind(this.framebufferTexture.framebuffer, framebufferSys.viewport);
    // Current framebuffer is binded as a read framebuffer (to prepare the blit)
    gl.bindFramebuffer(gl.READ_FRAMEBUFFER, fb?.glFramebuffers[framebufferSys.CONTEXT_UID].framebuffer);
    // Blit current framebuffer into our render texture
    gl.blitFramebuffer(srcX, srcY, srcX2, srcY2, dstX, dstY, dstX2, dstY2, gl.COLOR_BUFFER_BIT, gl.NEAREST);
    // Restore original behavior
    framebufferSys.bind(currentFramebuffer, framebufferSys.viewport);

    return this.framebufferTexture;
  }

  /* ---------------------------------------- */

  /**
   * Create a render texture, provide a render method and an optional clear color.
   * @returns {PIXI.RenderTexture}              A reference to the created render texture.
   */
  static #createRenderTexture() {
    const renderer = canvas.app?.renderer;
    return PIXI.RenderTexture.create({
      width: renderer?.screen.width ?? window.innerWidth,
      height: renderer?.screen.height ?? window.innerHeight,
      resolution: renderer.resolution ?? PIXI.settings.RESOLUTION
    });
  }
}

/**
 * A smooth noise generator for one-dimensional values.
 * @param {object} options                        Configuration options for the noise process.
 * @param {number} [options.amplitude=1]          The generated noise will be on the range [0, amplitude].
 * @param {number} [options.scale=1]              An adjustment factor for the input x values which place them on an
 *                                                appropriate range.
 * @param {number} [options.maxReferences=256]    The number of pre-generated random numbers to generate.
 */
class SmoothNoise {
  constructor({amplitude=1, scale=1, maxReferences=256}={}) {

    // Configure amplitude
    this.amplitude = amplitude;

    // Configure scale
    this.scale = scale;

    // Create pre-generated random references
    if ( !Number.isInteger(maxReferences) || !PIXI.utils.isPow2(maxReferences) ) {
      throw new Error("SmoothNoise maxReferences must be a positive power-of-2 integer.");
    }
    Object.defineProperty(this, "_maxReferences", {value: maxReferences || 1, writable: false});
    Object.defineProperty(this, "_references", {value: [], writable: false});
    for ( let i = 0; i < this._maxReferences; i++ ) {
      this._references.push(Math.random());
    }
  }

  /**
   * Amplitude of the generated noise output
   * The noise output is multiplied by this value
   * @type {number[]}
   */
  get amplitude() {
    return this._amplitude;
  }
  set amplitude(amplitude) {
    if ( !Number.isFinite(amplitude) || (amplitude === 0) ) {
      throw new Error("SmoothNoise amplitude must be a finite non-zero number.");
    }
    this._amplitude = amplitude;
  }
  _amplitude;

  /**
   * Scale factor of the random indices
   * @type {number[]}
   */
  get scale() {
    return this._scale;
  }
  set scale(scale) {
    if ( !Number.isFinite(scale) || (scale <= 0 ) ) {
      throw new Error("SmoothNoise scale must be a finite positive number.");
    }
    this._scale = scale;
  }
  _scale;

  /**
   * Generate the noise value corresponding to a provided numeric x value.
   * @param {number} x      Any finite number
   * @return {number}       The corresponding smoothed noise value
   */
  generate(x) {
    const scaledX = x * this._scale;                                         // The input x scaled by some factor
    const xFloor = Math.floor(scaledX);                                      // The integer portion of x
    const t = scaledX - xFloor;                                              // The fractional remainder, zero in the case of integer x
    const tSmooth = t * t * (3 - 2 * t);                                     // Smooth cubic [0, 1] for mixing between random numbers
    const i0 = xFloor & (this._maxReferences - 1);                           // The current index of the references array
    const i1 = (i0 + 1) & (this._maxReferences - 1);                         // The next index of the references array
    const y = Math.mix(this._references[i0], this._references[i1], tSmooth); // Smoothly mix between random numbers
    return y * this._amplitude;                                              // The final result is multiplied by the requested amplitude
  };
}
/**
 * A class or interface that provide support for WebGL async read pixel/texture data extraction.
 */
class TextureExtractor {
  constructor(renderer, {callerName, controlHash, format=PIXI.FORMATS.RED}={}) {
    this.#renderer = renderer;
    this.#callerName = callerName ?? "TextureExtractor";
    this.#compressor = new TextureCompressor("Compressor", {debug: false, controlHash});

    // Verify that the required format is supported by the texture extractor
    if ( !((format === PIXI.FORMATS.RED) || (format === PIXI.FORMATS.RGBA)) ) {
      throw new Error("TextureExtractor supports format RED and RGBA only.")
    }

    // Assign format, types, and read mode
    this.#format = format;
    this.#type = PIXI.TYPES.UNSIGNED_BYTE;
    this.#readFormat = (((format === PIXI.FORMATS.RED) && !canvas.supported.readPixelsRED)
      || format === PIXI.FORMATS.RGBA) ? PIXI.FORMATS.RGBA : PIXI.FORMATS.RED;

    // We need to intercept context change
    this.#renderer.runners.contextChange.add(this);
  }

  /**
   * List of compression that could be applied with extraction
   * @enum {number}
   */
  static COMPRESSION_MODES = {
    NONE: 0,
    BASE64: 1
  };

  /**
   * The WebGL2 renderer.
   * @type {Renderer}
   */
  #renderer;

  /**
   * The reference to a WebGL2 sync object.
   * @type {WebGLSync}
   */
  #glSync;

  /**
   * The texture format on which the Texture Extractor must work.
   * @type {PIXI.FORMATS}
   */
  #format

  /**
   * The texture type on which the Texture Extractor must work.
   * @type {PIXI.TYPES}
   */
  #type

  /**
   * The texture format on which the Texture Extractor should read.
   * @type {PIXI.FORMATS}
   */
  #readFormat

  /**
   * The reference to the GPU buffer.
   * @type {WebGLBuffer}
   */
  #gpuBuffer;

  /**
   * To know if we need to create a GPU buffer.
   * @type {boolean}
   */
  #createBuffer;

  /**
   * Debug flag.
   * @type {boolean}
   */
  debug;

  /**
   * The reference to the pixel buffer.
   * @type {Uint8ClampedArray}
   */
  pixelBuffer;

  /**
   * The caller name associated with this instance of texture extractor (optional, used for debug)
   * @type {string}
   */
  #callerName;

  /**
   * Generated RenderTexture for textures.
   * @type {PIXI.RenderTexture}
   */
  #generatedRenderTexture;

  /* -------------------------------------------- */
  /*  TextureExtractor Compression Worker         */
  /* -------------------------------------------- */

  /**
   * The compressor worker wrapper
   * @type {TextureCompressor}
   */
  #compressor;

  /* -------------------------------------------- */
  /*  TextureExtractor Properties                 */
  /* -------------------------------------------- */

  /**
   * Returns the read buffer width/height multiplier.
   * @returns {number}
   */
  get #readBufferMul() {
    return this.#readFormat === PIXI.FORMATS.RED ? 1 : 4;
  }

  /* -------------------------------------------- */
  /*  TextureExtractor Synchronization            */
  /* -------------------------------------------- */

  /**
   * Handling of the concurrency for the extraction (by default a queue of 1)
   * @type {Semaphore}
   */
  #queue = new foundry.utils.Semaphore();

  /* -------------------------------------------- */

  /**
   * @typedef {Object} TextureExtractionOptions
   * @property {PIXI.Texture|PIXI.RenderTexture|null} [texture]   The texture the pixels are extracted from.
   *                                                              Otherwise, extract from the renderer.
   * @property {PIXI.Rectangle} [frame]                           The rectangle which the pixels are extracted from.
   * @property {TextureExtractor.COMPRESSION_MODES} [compression] The compression mode to apply, or NONE
   * @property {string}         [type]                            The optional image mime type.
   * @property {string}         [quality]                         The optional image quality.
   * @property {boolean} [debug]                                  The optional debug flag to use.
   */

  /**
   * Extract a rectangular block of pixels from the texture (without un-pre-multiplying).
   * @param {TextureExtractionOptions} options                    Options which configure extraction behavior
   * @returns {Promise}
   */
  async extract(options={}) {
    return this.#queue.add(this.#extract.bind(this), options);
  }

  /* -------------------------------------------- */
  /*  TextureExtractor Methods/Interface          */
  /* -------------------------------------------- */

  /**
   * Extract a rectangular block of pixels from the texture (without un-pre-multiplying).
   * @param {TextureExtractionOptions} options                    Options which configure extraction behavior
   * @returns {Promise}
   */
  async #extract({texture, frame, compression, type, quality, debug}={}) {

    // Set the debug flag
    this.debug = debug;
    if ( this.debug ) this.#consoleDebug("Begin texture extraction.");

    // Checking texture validity
    const baseTexture = texture?.baseTexture;
    if ( texture && (!baseTexture || !baseTexture.valid || baseTexture.parentTextureArray) ) {
      throw new Error("Texture passed to extractor is invalid.");
    }

    // Checking if texture is in RGBA format and premultiplied
    if ( texture && (texture.baseTexture.alphaMode > 0) && (texture.baseTexture.format === PIXI.FORMATS.RGBA) ) {
      throw new Error("Texture Extractor is not supporting premultiplied textures yet.");
    }

    let resolution;

    // If the texture is a RT, use its frame and resolution
    if ( (texture instanceof PIXI.RenderTexture) && ((baseTexture.format === this.#format)
        || (this.#readFormat === PIXI.FORMATS.RGBA) )
      && (baseTexture.type === this.#type) ) {
      frame ??= texture.frame;
      resolution = baseTexture.resolution;
    }
    // Case when the texture is not a render texture
    // Generate a render texture and assign frame and resolution from it
    else {
      texture = this.#generatedRenderTexture = this.#renderer.generateTexture(new PIXI.Sprite(texture), {
        format: this.#format,
        type: this.#type,
        resolution: baseTexture.resolution,
        multisample: PIXI.MSAA_QUALITY.NONE
      });
      frame ??= this.#generatedRenderTexture.frame;
      resolution = texture.baseTexture.resolution;
    }

    // Bind the texture
    this.#renderer.renderTexture.bind(texture);

    // Get the buffer from the GPU
    const data = await this.#readPixels(frame, resolution);

    // Return the compressed image or the raw buffer
    if ( compression ) {
      return await this.#compressBuffer(data.buffer, data.width, data.height, {compression, type, quality});
    }
    else if ( (this.#format === PIXI.FORMATS.RED) && (this.#readFormat === PIXI.FORMATS.RGBA) ) {
      const result = await this.#compressor.reduceBufferRGBAToBufferRED(data.buffer, data.width, data.height, {compression, type, quality});
      // Returning control of the buffer to the extractor
      this.pixelBuffer = result.buffer;
      // Returning the result
      return result.redBuffer;
    }
    return data.buffer;
  }

  /* -------------------------------------------- */

  /**
   * Free all the bound objects.
   */
  reset() {
    if ( this.debug ) this.#consoleDebug("Data reset.");
    this.#clear({buffer: true, syncObject: true, rt: true});
  }

  /* -------------------------------------------- */

  /**
   * Called by the renderer contextChange runner.
   */
  contextChange() {
    if ( this.debug ) this.#consoleDebug("WebGL context has changed.");
    this.#glSync = undefined;
    this.#generatedRenderTexture = undefined;
    this.#gpuBuffer = undefined;
    this.pixelBuffer = undefined;
  }

  /* -------------------------------------------- */
  /*  TextureExtractor Management                 */
  /* -------------------------------------------- */


  /**
   * Compress the buffer and returns a base64 image.
   * @param {*} args
   * @returns {Promise<string>}
   */
  async #compressBuffer(...args) {
    if ( canvas.supported.offscreenCanvas ) return this.#compressBufferWorker(...args);
    else return this.#compressBufferLocal(...args);
  }

  /* -------------------------------------------- */

  /**
   * Compress the buffer into a worker and returns a base64 image
   * @param {Uint8ClampedArray} buffer          Buffer to convert into a compressed base64 image.
   * @param {number} width                      Width of the image.
   * @param {number} height                     Height of the image.
   * @param {object} options
   * @param {string} options.type               Format of the image.
   * @param {number} options.quality            Quality of the compression.
   * @returns {Promise<string>}
   */
  async #compressBufferWorker(buffer, width, height, {type, quality}={}) {
    let result;
    try {
      // Launch compression
      result = await this.#compressor.compressBufferBase64(buffer, width, height, {
        type: type ?? "image/png",
        quality: quality ?? 1,
        debug: this.debug,
        readFormat: this.#readFormat
      });
    }
    catch(e) {
      this.#consoleError("Buffer compression has failed!");
      throw e;
    }
    // Returning control of the buffer to the extractor
    this.pixelBuffer = result.buffer;
    // Returning the result
    return result.base64img;
  }

  /* -------------------------------------------- */

  /**
   * Compress the buffer locally (but expand the buffer into a worker) and returns a base64 image.
   * The image format is forced to jpeg.
   * @param {Uint8ClampedArray} buffer          Buffer to convert into a compressed base64 image.
   * @param {number} width                      Width of the image.
   * @param {number} height                     Height of the image.
   * @param {object} options
   * @param {number} options.quality            Quality of the compression.
   * @returns {Promise<string>}
   */
  async #compressBufferLocal(buffer, width, height, {quality}={}) {
    let rgbaBuffer;
    if ( this.#readFormat === PIXI.FORMATS.RED ) {
      let result;
      try {
        // Launch buffer expansion on the worker thread
        result = await this.#compressor.expandBufferRedToBufferRGBA(buffer, width, height, {
          debug: this.debug
        });
      } catch(e) {
        this.#consoleError("Buffer expansion has failed!");
        throw e;
      }
      // Returning control of the buffer to the extractor
      this.pixelBuffer = result.buffer;
      rgbaBuffer = result.rgbaBuffer;
    } else {
      rgbaBuffer = buffer;
    }
    if ( !rgbaBuffer ) return;

    // Proceed at the compression locally and return the base64 image
    const element = ImageHelper.pixelsToCanvas(rgbaBuffer, width, height);
    return await ImageHelper.canvasToBase64(element, "image/jpeg", quality); // Force jpeg compression
  }

  /* -------------------------------------------- */

  /**
   * Prepare data for the asynchronous readPixel.
   * @param {PIXI.Rectangle} frame
   * @param {number} resolution
   * @returns {object}
   */
  async #readPixels(frame, resolution) {
    const gl = this.#renderer.gl;

    // Set dimensions and buffer size
    const x = Math.round(frame.left * resolution);
    const y = Math.round(frame.top * resolution);
    const width = Math.round(frame.width * resolution);
    const height = Math.round(frame.height * resolution);
    const bufSize = width * height * this.#readBufferMul;

    // Set format and type needed for the readPixel command
    const format = this.#readFormat;
    const type = gl.UNSIGNED_BYTE;

    // Useful debug information
    if ( this.debug ) console.table({x, y, width, height, bufSize, format, type, extractorFormat: this.#format});

    // The buffer that will hold the pixel data
    const pixels = this.#getPixelCache(bufSize);

    // Start the non-blocking read
    // Create or reuse the GPU buffer and bind as buffer data
    if ( this.#createBuffer ) {
      if ( this.debug ) this.#consoleDebug("Creating buffer.");
      this.#createBuffer = false;
      if ( this.#gpuBuffer ) this.#clear({buffer: true});
      this.#gpuBuffer = gl.createBuffer();
      gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.#gpuBuffer);
      gl.bufferData(gl.PIXEL_PACK_BUFFER, bufSize, gl.DYNAMIC_READ);
    }
    else {
      if ( this.debug ) this.#consoleDebug("Reusing cached buffer.");
      gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.#gpuBuffer);
    }

    // Performs read pixels GPU Texture -> GPU Buffer
    gl.pixelStorei(gl.PACK_ALIGNMENT, 1);
    gl.readPixels(x, y, width, height, format, type, 0);
    gl.pixelStorei(gl.PACK_ALIGNMENT, 4);
    gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);

    // Declare the sync object
    this.#glSync = gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0);

    // Flush all pending gl commands, including the commands above (important: flush is non blocking)
    // The glSync will be complete when all commands will be executed
    gl.flush();

    // Waiting for the sync object to resolve
    await this.#wait();

    // Retrieve the GPU buffer data
    const data = this.#getGPUBufferData(pixels, width, height, bufSize);

    // Clear the sync object and possible generated render texture
    this.#clear({syncObject: true, rt: true});

    // Return the data
    if ( this.debug ) this.#consoleDebug("Buffer data sent to caller.");

    return data;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the content of the GPU buffer and put it pixels.
   * Returns an object with the pixel buffer and dimensions.
   * @param {Uint8ClampedArray} buffer                        The pixel buffer.
   * @param {number} width                                    The width of the texture.
   * @param {number} height                                   The height of the texture.
   * @param {number} bufSize                                  The size of the buffer.
   * @returns {object<Uint8ClampedArray, number, number>}
   */
  #getGPUBufferData(buffer, width, height, bufSize) {
    const gl = this.#renderer.gl;

    // Retrieve the GPU buffer data
    gl.bindBuffer(gl.PIXEL_PACK_BUFFER, this.#gpuBuffer);
    gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, buffer, 0, bufSize);
    gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null);

    return {buffer, width, height};
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a pixel buffer of the given length.
   * A cache is provided for the last length passed only (to avoid too much memory consumption)
   * @param {number} length           Length of the required buffer.
   * @returns {Uint8ClampedArray}     The cached or newly created buffer.
   */
  #getPixelCache(length) {
    if ( this.pixelBuffer?.length !== length ) {
      this.pixelBuffer = new Uint8ClampedArray(length);
      // If the pixel cache need to be (re)created, the same for the GPU buffer
      this.#createBuffer = true;
    }
    return this.pixelBuffer;
  }

  /* -------------------------------------------- */

  /**
   * Wait for the synchronization object to resolve.
   * @returns {Promise}
   */
  async #wait() {
    // Preparing data for testFence
    const gl = this.#renderer.gl;
    const sync = this.#glSync;

    // Prepare for fence testing
    const result = await new Promise((resolve, reject) => {
      /**
       * Test the fence sync object
       */
      function wait() {
        const res = gl.clientWaitSync(sync, 0, 0);
        if ( res === gl.WAIT_FAILED ) {
          reject(false);
          return;
        }
        if ( res === gl.TIMEOUT_EXPIRED ) {
          setTimeout(wait, 10);
          return;
        }
        resolve(true);
      }
      wait();
    });

    // The promise was rejected?
    if ( !result ) {
      this.#clear({buffer: true, syncObject: true, data: true, rt: true});
      throw new Error("The sync object has failed to wait.");
    }
  }

  /* -------------------------------------------- */

  /**
   * Clear some key properties.
   * @param {object} options
   * @param {boolean} [options.buffer=false]
   * @param {boolean} [options.syncObject=false]
   * @param {boolean} [options.rt=false]
   */
  #clear({buffer=false, syncObject=false, rt=false}={}) {
    if ( syncObject && this.#glSync ) {
      // Delete the sync object
      this.#renderer.gl.deleteSync(this.#glSync);
      this.#glSync = undefined;
      if ( this.debug ) this.#consoleDebug("Free the sync object.");
    }
    if ( buffer ) {
      // Delete the buffers
      if ( this.#gpuBuffer ) {
        this.#renderer.gl.deleteBuffer(this.#gpuBuffer);
        this.#gpuBuffer = undefined;
      }
      this.pixelBuffer = undefined;
      this.#createBuffer = true;
      if ( this.debug ) this.#consoleDebug("Free the cached buffers.");
    }
    if ( rt && this.#generatedRenderTexture ) {
      // Delete the generated render texture
      this.#generatedRenderTexture.destroy(true);
      this.#generatedRenderTexture = undefined;
      if ( this.debug ) this.#consoleDebug("Destroy the generated render texture.");
    }
  }

  /* -------------------------------------------- */

  /**
   * Convenience method to display the debug messages with the extractor.
   * @param {string} message      The debug message to display.
   */
  #consoleDebug(message) {
    console.debug(`${this.#callerName} | ${message}`);
  }

  /* -------------------------------------------- */

  /**
   * Convenience method to display the error messages with the extractor.
   * @param {string} message      The error message to display.
   */
  #consoleError(message) {
    console.error(`${this.#callerName} | ${message}`);
  }
}

/**
 * This class defines an interface for masked custom filters
 */
class AbstractBaseMaskFilter extends AbstractBaseFilter {
  /**
   * The default vertex shader used by all instances of AbstractBaseMaskFilter
   * @type {string}
   */
  static vertexShader = `
  attribute vec2 aVertexPosition;

  uniform mat3 projectionMatrix;
  uniform vec2 screenDimensions;
  uniform vec4 inputSize;
  uniform vec4 outputFrame;

  varying vec2 vTextureCoord;
  varying vec2 vMaskTextureCoord;

  vec4 filterVertexPosition( void ) {
      vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;
      return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0., 1.);
  }

  // getting normalized coord for the tile texture
  vec2 filterTextureCoord( void ) {
      return aVertexPosition * (outputFrame.zw * inputSize.zw);
  }

  // getting normalized coord for a screen sized mask render texture
  vec2 filterMaskTextureCoord( in vec2 textureCoord ) {
    return (textureCoord * inputSize.xy + outputFrame.xy) / screenDimensions;
  }

  void main() {
    vTextureCoord = filterTextureCoord();
    vMaskTextureCoord = filterMaskTextureCoord(vTextureCoord);
    gl_Position = filterVertexPosition();
  }`;

  /** @override */
  apply(filterManager, input, output, clear, currentState) {
    this.uniforms.screenDimensions = canvas.screenDimensions;
    filterManager.applyFilter(this, input, output, clear);
  }
}

/**
 * Apply a vertical or horizontal gaussian blur going inward by using alpha as the penetrating channel.
 * @param {boolean} horizontal      If the pass is horizontal (true) or vertical (false).
 * @param {number} [strength=8]     Strength of the blur (distance of sampling).
 * @param {number} [quality=4]      Number of passes to generate the blur. More passes = Higher quality = Lower Perf.
 * @param {number} [resolution=PIXI.Filter.defaultResolution]  Resolution of the filter.
 * @param {number} [kernelSize=5]   Number of kernels to use. More kernels = Higher quality = Lower Perf.
 */
class AlphaBlurFilterPass extends PIXI.Filter {
  constructor(horizontal, strength=8, quality=4, resolution=PIXI.Filter.defaultResolution, kernelSize=5) {
    const vertSrc = AlphaBlurFilterPass.vertTemplate(kernelSize, horizontal);
    const fragSrc = AlphaBlurFilterPass.fragTemplate(kernelSize);
    super(vertSrc, fragSrc);
    this.horizontal = horizontal;
    this.strength = strength;
    this.passes = quality;
    this.resolution = resolution;
  }

  /**
   * If the pass is horizontal (true) or vertical (false).
   * @type {boolean}
   */
  horizontal;

  /**
   * Strength of the blur (distance of sampling).
   * @type {number}
   */
  strength;

  /**
   * The number of passes to generate the blur.
   * @type {number}
   */
  passes;

  /* -------------------------------------------- */

  /**
   * The quality of the filter is defined by its number of passes.
   * @returns {number}
   */
  get quality() {
    return this.passes;
  }

  set quality(value) {
    this.passes = value;
  }

  /* -------------------------------------------- */

  /**
   * The strength of the blur filter in pixels.
   * @returns {number}
   */
  get blur() {
    return this.strength;
  }

  set blur(value) {
    this.padding = 1 + (Math.abs(value) * 2);
    this.strength = value;
  }

  /* -------------------------------------------- */

  /**
   * The kernels containing the gaussian constants.
   * @type {Record<number, number[]>}
   */
  static GAUSSIAN_VALUES = {
    5: [0.153388, 0.221461, 0.250301],
    7: [0.071303, 0.131514, 0.189879, 0.214607],
    9: [0.028532, 0.067234, 0.124009, 0.179044, 0.20236],
    11: [0.0093, 0.028002, 0.065984, 0.121703, 0.175713, 0.198596],
    13: [0.002406, 0.009255, 0.027867, 0.065666, 0.121117, 0.174868, 0.197641],
    15: [0.000489, 0.002403, 0.009246, 0.02784, 0.065602, 0.120999, 0.174697, 0.197448]
  };

  /* -------------------------------------------- */

  /**
   * The fragment template generator
   * @param {number} kernelSize   The number of kernels to use.
   * @returns {string}            The generated fragment shader.
   */
  static fragTemplate(kernelSize) {
    return `
    varying vec2 vBlurTexCoords[${kernelSize}];
    varying vec2 vTextureCoords;
    uniform sampler2D uSampler;

    void main(void) {
        vec4 finalColor = vec4(0.0);
        ${this.generateBlurFragSource(kernelSize)}
        finalColor.rgb *= clamp(mix(-1.0, 1.0, finalColor.a), 0.0, 1.0);
        gl_FragColor = finalColor;
    }
    `;
  }

  /* -------------------------------------------- */

  /**
   * The vertex template generator
   * @param {number} kernelSize   The number of kernels to use.
   * @param {boolean} horizontal  If the vertex should handle horizontal or vertical pass.
   * @returns {string}            The generated vertex shader.
   */
  static vertTemplate(kernelSize, horizontal) {
    return `
    attribute vec2 aVertexPosition;
    uniform mat3 projectionMatrix;
    uniform float strength;
    varying vec2 vBlurTexCoords[${kernelSize}];
    varying vec2 vTextureCoords;
    uniform vec4 inputSize;
    uniform vec4 outputFrame;
    
    vec4 filterVertexPosition( void ) {
        vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;
        return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);
    }
    
    vec2 filterTextureCoord( void ) {
        return aVertexPosition * (outputFrame.zw * inputSize.zw);
    }
        
    void main(void) {
        gl_Position = filterVertexPosition();
        vec2 textureCoord = vTextureCoords = filterTextureCoord();
        ${this.generateBlurVertSource(kernelSize, horizontal)}
    }
    `;
  }

  /* -------------------------------------------- */

  /**
   * Generating the dynamic part of the blur in the fragment
   * @param {number} kernelSize   The number of kernels to use.
   * @returns {string}            The dynamic blur part.
   */
  static generateBlurFragSource(kernelSize) {
    const kernel = AlphaBlurFilterPass.GAUSSIAN_VALUES[kernelSize];
    const halfLength = kernel.length;
    let value;
    let blurLoop = "";
    for ( let i = 0; i < kernelSize; i++ ) {
      blurLoop += `finalColor += texture2D(uSampler, vBlurTexCoords[${i.toString()}])`;
      value = i >= halfLength ? kernelSize - i - 1 : i;
      blurLoop += ` * ${kernel[value].toString()};\n`;
    }
    return blurLoop;
  }

  /* -------------------------------------------- */

  /**
   * Generating the dynamic part of the blur in the vertex
   * @param {number} kernelSize   The number of kernels to use.
   * @param {boolean} horizontal  If the vertex should handle horizontal or vertical pass.
   * @returns {string}            The dynamic blur part.
   */
  static generateBlurVertSource(kernelSize, horizontal) {
    const halfLength = Math.ceil(kernelSize / 2);
    let blurLoop = "";
    for ( let i = 0; i < kernelSize; i++ ) {
      const khl = i - (halfLength - 1);
      blurLoop += horizontal
        ? `vBlurTexCoords[${i.toString()}] = textureCoord + vec2(${khl}.0 * strength, 0.0);`
        : `vBlurTexCoords[${i.toString()}] = textureCoord + vec2(0.0, ${khl}.0 * strength);`;
      blurLoop += "\n";
    }
    return blurLoop;
  }

  /* -------------------------------------------- */

  /** @override */
  apply(filterManager, input, output, clearMode) {

    // Define strength
    const ow = output ? output.width : filterManager.renderer.width;
    const oh = output ? output.height : filterManager.renderer.height;
    this.uniforms.strength = (this.horizontal ? (1 / ow) * (ow / input.width) : (1 / oh) * (oh / input.height))
      * this.strength / this.passes;

    // Single pass
    if ( this.passes === 1 ) {
      return filterManager.applyFilter(this, input, output, clearMode);
    }

    // Multi-pass
    const renderTarget = filterManager.getFilterTexture();
    const renderer = filterManager.renderer;

    let flip = input;
    let flop = renderTarget;

    // Initial application
    this.state.blend = false;
    filterManager.applyFilter(this, flip, flop, PIXI.CLEAR_MODES.CLEAR);

    // Additional passes
    for ( let i = 1; i < this.passes - 1; i++ ) {
      filterManager.bindAndClear(flip, PIXI.CLEAR_MODES.BLIT);
      this.uniforms.uSampler = flop;
      const temp = flop;
      flop = flip;
      flip = temp;
      renderer.shader.bind(this);
      renderer.geometry.draw(5);
    }

    // Final pass and return filter texture
    this.state.blend = true;
    filterManager.applyFilter(this, flop, output, clearMode);
    filterManager.returnFilterTexture(renderTarget);
  }
}

/* -------------------------------------------- */

/**
 * Apply a gaussian blur going inward by using alpha as the penetrating channel.
 * @param {number} [strength=8]     Strength of the blur (distance of sampling).
 * @param {number} [quality=4]      Number of passes to generate the blur. More passes = Higher quality = Lower Perf.
 * @param {number} [resolution=PIXI.Filter.defaultResolution]  Resolution of the filter.
 * @param {number} [kernelSize=5]   Number of kernels to use. More kernels = Higher quality = Lower Perf.
 */
class AlphaBlurFilter extends PIXI.Filter {
  constructor(strength=8, quality=4, resolution=PIXI.Filter.defaultResolution, kernelSize=5) {
    super();
    this.blurXFilter = new AlphaBlurFilterPass(true, strength, quality, resolution, kernelSize);
    this.blurYFilter = new AlphaBlurFilterPass(false, strength, quality, resolution, kernelSize);
    this.resolution = resolution;
    this._repeatEdgePixels = false;
    this.quality = quality;
    this.blur = strength;
  }

  /* -------------------------------------------- */

  /** @override */
  apply(filterManager, input, output, clearMode) {
    const xStrength = Math.abs(this.blurXFilter.strength);
    const yStrength = Math.abs(this.blurYFilter.strength);

    // Blur both directions
    if ( xStrength && yStrength ) {
      const renderTarget = filterManager.getFilterTexture();
      this.blurXFilter.apply(filterManager, input, renderTarget, PIXI.CLEAR_MODES.CLEAR);
      this.blurYFilter.apply(filterManager, renderTarget, output, clearMode);
      filterManager.returnFilterTexture(renderTarget);
    }

    // Only vertical
    else if ( yStrength ) this.blurYFilter.apply(filterManager, input, output, clearMode);

    // Only horizontal
    else this.blurXFilter.apply(filterManager, input, output, clearMode);
  }

  /* -------------------------------------------- */

  /**
   * Update the filter padding according to the blur strength value (0 if _repeatEdgePixels is active)
   */
  updatePadding() {
    this.padding = this._repeatEdgePixels ? 0
      : Math.max(Math.abs(this.blurXFilter.strength), Math.abs(this.blurYFilter.strength)) * 2;
  }

  /* -------------------------------------------- */

  /**
   * The amount of blur is forwarded to the X and Y filters.
   * @type {number}
   */
  get blur() {
    return this.blurXFilter.blur;
  }

  set blur(value) {
    this.blurXFilter.blur = this.blurYFilter.blur = value;
    this.updatePadding();
  }

  /* -------------------------------------------- */

  /**
   * The quality of blur defines the number of passes used by subsidiary filters.
   * @type {number}
   */
  get quality() {
    return this.blurXFilter.quality;
  }

  set quality(value) {
    this.blurXFilter.quality = this.blurYFilter.quality = value;
  }

  /* -------------------------------------------- */

  /**
   * Whether to repeat edge pixels, adding padding to the filter area.
   * @type {boolean}
   */
  get repeatEdgePixels() {
    return this._repeatEdgePixels;
  }

  set repeatEdgePixels(value) {
    this._repeatEdgePixels = value;
    this.updatePadding();
  }

  /* -------------------------------------------- */

  /**
   * Provided for completeness with PIXI.BlurFilter
   * @type {number}
   */
  get blurX() {
    return this.blurXFilter.blur;
  }

  set blurX(value) {
    this.blurXFilter.blur = value;
    this.updatePadding();
  }

  /* -------------------------------------------- */

  /**
   * Provided for completeness with PIXI.BlurFilter
   * @type {number}
   */
  get blurY() {
    return this.blurYFilter.blur;
  }

  set blurY(value) {
    this.blurYFilter.blur = value;
    this.updatePadding();
  }

  /* -------------------------------------------- */

  /**
   * Provided for completeness with PIXI.BlurFilter
   * @type {number}
   */
  get blendMode() {
    return this.blurYFilter.blendMode;
  }

  set blendMode(value) {
    this.blurYFilter.blendMode = value;
  }
}

/**
 * This filter handles masking and post-processing for visual effects.
 */
class VisualEffectsMaskingFilter extends AbstractBaseMaskFilter {
  /** @override */
  static create({postProcessModes, ...initialUniforms}={}) {
    const fragmentShader = this.fragmentShader(postProcessModes);
    const uniforms = {...this.defaultUniforms, ...initialUniforms};
    return new this(this.vertexShader, fragmentShader, uniforms);
  }

  /**
   * Code to determine which post-processing effect is applied in this filter.
   * @type {string[]}
   */
  #postProcessModes;

  /* -------------------------------------------- */

  /**
   * Masking modes.
   * @enum {number}
   */
  static FILTER_MODES = Object.freeze({
    BACKGROUND: 0,
    ILLUMINATION: 1,
    COLORATION: 2
  });

  /* -------------------------------------------- */

  /** @override */
  static defaultUniforms = {
    tint: [1, 1, 1],
    screenDimensions: [1, 1],
    enableVisionMasking: true,
    visionTexture: null,
    darknessLevelTexture: null,
    exposure: 0,
    contrast: 0,
    saturation: 0,
    mode: 0,
    ambientDarkness: [0, 0, 0],
    ambientDaylight: [1, 1, 1],
    replacementColor: [0, 0, 0]
  };

  /* -------------------------------------------- */

  /**
   * Update the filter shader with new post-process modes.
   * @param {string[]} [postProcessModes=[]]   New modes to apply.
   * @param {object} [uniforms={}]             Uniforms value to update.
   */
  updatePostprocessModes(postProcessModes=[], uniforms={}) {

    // Update shader uniforms
    for ( let [uniform, value] of Object.entries(uniforms) ) {
      if ( uniform in this.uniforms ) this.uniforms[uniform] = value;
    }

    // Update the shader program if post-processing modes have changed
    if ( postProcessModes.equals(this.#postProcessModes) ) return;
    this.#postProcessModes = postProcessModes;
    this.program = PIXI.Program.from(this.constructor.vertexShader,
      this.constructor.fragmentShader(this.#postProcessModes));
  }

  /* -------------------------------------------- */

  /**
   * Remove all post-processing modes and reset some key uniforms.
   */
  reset() {
    this.#postProcessModes = [];
    this.program = PIXI.Program.from(this.constructor.vertexShader,
      this.constructor.fragmentShader());
    const uniforms = ["tint", "exposure", "contrast", "saturation"];
    for ( const uniform of uniforms ) {
      this.uniforms[uniform] = this.constructor.defaultUniforms[uniform];
    }
  }

  /* -------------------------------------------- */

  /** @override */
  apply(filterManager, input, output, clear, currentState) {
    const c = canvas.colors;
    const u = this.uniforms;
    if ( u.mode === this.constructor.FILTER_MODES.ILLUMINATION ) {
      c.ambientDarkness.applyRGB(u.ambientDarkness);
      c.ambientDaylight.applyRGB(u.ambientDaylight);
    }
    super.apply(filterManager, input, output, clear, currentState);
  }

  /* -------------------------------------------- */

  /**
   * Filter post-process techniques.
   * @enum {{id: string, glsl: string}}
   */
  static POST_PROCESS_TECHNIQUES = {
    EXPOSURE: {
      id: "EXPOSURE",
      glsl: `if ( exposure != 0.0 ) {
        finalColor.rgb *= (1.0 + exposure);
      }`
    },
    CONTRAST: {
      id: "CONTRAST",
      glsl: `if ( contrast != 0.0 ) {
        finalColor.rgb = (finalColor.rgb - 0.5) * (contrast + 1.0) + 0.5;
      }`
    },
    SATURATION: {
      id: "SATURATION",
      glsl: `if ( saturation != 0.0 ) {
        float reflection = perceivedBrightness(finalColor.rgb);
        finalColor.rgb = mix(vec3(reflection), finalColor.rgb, 1.0 + saturation) * finalColor.a;
      }`
    }
  };

  /* -------------------------------------------- */

  /**
   * Memory allocations and headers for the VisualEffectsMaskingFilter
   * @returns {string}                   The filter header according to the filter mode.
   */
  static fragmentHeader = `
    varying vec2 vTextureCoord;
    varying vec2 vMaskTextureCoord;
    uniform float contrast;
    uniform float saturation;
    uniform float exposure;
    uniform vec3 ambientDarkness;
    uniform vec3 ambientDaylight;
    uniform vec3 replacementColor;
    uniform vec3 tint;
    uniform sampler2D uSampler;
    uniform sampler2D visionTexture;
    uniform sampler2D darknessLevelTexture;
    uniform bool enableVisionMasking;
    uniform int mode;
    vec4 baseColor;
    vec4 finalColor;
    ${this.CONSTANTS}
    ${this.PERCEIVED_BRIGHTNESS}
    
    vec4 getReplacementColor() {
      if ( mode == 0 ) return vec4(0.0);
      if ( mode == 2 ) return vec4(replacementColor, 1.0);
      float darknessLevel = texture2D(darknessLevelTexture, vMaskTextureCoord).r;
      return vec4(mix(ambientDaylight, ambientDarkness, darknessLevel), 1.0);
    }
    `;

  /* -------------------------------------------- */

  /**
   * The fragment core code.
   * @type {string}
   */
  static fragmentCore = `
    // Get the base color from the filter sampler
    finalColor = texture2D(uSampler, vTextureCoord);
    
    // Handling vision masking  
    if ( enableVisionMasking ) {
      finalColor = mix( getReplacementColor(), 
                        finalColor, 
                        texture2D(visionTexture, vMaskTextureCoord).r);
    }
    `;

  /* -------------------------------------------- */

  /**
   * Construct filter post-processing code according to provided value.
   * @param {string[]} postProcessModes  Post-process modes to construct techniques.
   * @returns {string}                   The constructed shader code for post-process techniques.
   */
  static fragmentPostProcess(postProcessModes=[]) {
    return postProcessModes.reduce((s, t) => s + this.POST_PROCESS_TECHNIQUES[t].glsl ?? "", "");
  }

  /* -------------------------------------------- */

  /**
   * Specify the fragment shader to use according to mode
   * @param {string[]} postProcessModes
   * @returns {string}
   * @override
   */
  static fragmentShader(postProcessModes=[]) {
    return `
    ${this.fragmentHeader}
    void main() {
      ${this.fragmentCore}
      ${this.fragmentPostProcess(postProcessModes)}
      if ( enableVisionMasking ) finalColor *= vec4(tint, 1.0);
      gl_FragColor = finalColor;
    }
    `;
  }
}

/**
 * A filter used to apply color adjustments and other modifications to the environment.
 */
class PrimaryCanvasGroupAmbienceFilter extends AbstractBaseMaskFilter {
  /** @override */
  static fragmentShader = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;
    
    // Base ambience uniforms
    uniform vec3 baseTint;
    uniform float baseIntensity;
    uniform float baseLuminosity;
    uniform float baseSaturation;
    uniform float baseShadows;
    
    // Darkness ambience uniforms
    uniform vec3 darkTint;
    uniform float darkIntensity;
    uniform float darkLuminosity;
    uniform float darkSaturation;
    uniform float darkShadows;
    
    // Cycle enabled or disabled
    uniform bool cycle;
    
    // Textures
    uniform sampler2D darknessLevelTexture;
    uniform sampler2D uSampler;
    
    // Varyings
    varying vec2 vTextureCoord;
    varying vec2 vMaskTextureCoord;
    
    ${this.CONSTANTS}
    ${this.COLOR_SPACES}
    
    // Ambience parameters computed according to darkness level (per pixel)
    vec3 tint;
    float intensity;
    float luminosity;
    float saturation;
    float shadows;
    
    /* ----------------------------------------------------------------- */
    /*  Compute ambience parameters according to darkness level texture  */
    /* ----------------------------------------------------------------- */
    void computeAmbienceParameters() {
      float dl = texture2D(darknessLevelTexture, vMaskTextureCoord).r;
  
      // Determine the tint based on base and dark ambience parameters
      if ( baseIntensity > 0.0 ) tint = (cycle && darkIntensity > 0.0) ? mix(baseTint, darkTint, dl) : baseTint;
      else if ( darkIntensity > 0.0 && cycle ) tint = darkTint;
      else tint = vec3(1.0);
       
      // Compute the luminosity based on the cycle condition
      float luminosityBase = cycle ? mix(baseLuminosity, darkLuminosity, dl) : baseLuminosity;
      luminosity = luminosityBase * (luminosityBase >= 0.0 ? 1.2 : 0.8);
  
      // Compute the shadows based on the cycle condition
      shadows = (cycle ? mix(baseShadows, darkShadows, dl) : baseShadows) * 0.15;
  
      // Using a non-linear easing with intensity input value: x^2
      intensity = cycle ? mix(baseIntensity * baseIntensity, darkIntensity * darkIntensity, dl) 
                        : baseIntensity * baseIntensity;
      
      // Compute the saturation based on the cycle condition
      saturation = cycle ? mix(baseSaturation, darkSaturation, dl) : baseSaturation;
    }
    
    /* -------------------------------------------- */
          
    void main() {
      vec4 baseColor = texture2D(uSampler, vTextureCoord);
      
      if ( baseColor.a > 0.0 ) {
        computeAmbienceParameters();
        
        // Unmultiply rgb with alpha channel
        baseColor.rgb /= baseColor.a;
        
        // Apply shadows and luminosity on sRGB values
        if ( shadows > 0.0 ) {
          float l = luminance(srgb2linearFast(baseColor.rgb));
          baseColor.rgb *= min(l / shadows, 1.0);
        }
        if ( luminosity != 0.0 ) baseColor.rgb *= (1.0 + luminosity);
        
        baseColor.rgb = srgb2linear(baseColor.rgb);    // convert to linear before saturating and tinting
       
        // Apply saturation and tint on linearized rgb
        if ( saturation != 0.0 ) baseColor.rgb = mix(linear2grey(baseColor.rgb), baseColor.rgb, 1.0 + saturation);
        if ( intensity > 0.0 ) baseColor.rgb = tintColorLinear(colorClamp(baseColor.rgb), tint, intensity);
        else baseColor.rgb = colorClamp(baseColor.rgb);
        
        baseColor.rgb = linear2srgb(baseColor.rgb);    // convert back to sRGB
        
        // Multiply rgb with alpha channel
        baseColor.rgb *= baseColor.a;
      }
  
      // Output the result
      gl_FragColor = baseColor;
    }
  `;

  /** @override */
  static defaultUniforms = {
    uSampler: null,
    darknessLevelTexture: null,
    cycle: true,
    baseTint: [1, 1, 1], // Important: The base tint uniform must be in linear RGB!
    baseIntensity: 0,
    baseLuminosity: 0,
    baseSaturation: 0,
    baseShadows: 0,
    darkTint: [1, 1, 1], // Important: The dark tint uniform must be in linear RGB!
    darkIntensity: 0,
    darkLuminosity: 0,
    darkSaturation: 0,
    darkShadows: 0
  };
}

/**
 * A filter which implements an inner or outer glow around the source texture.
 * Inspired from https://github.com/pixijs/filters/tree/main/filters/glow
 * @license MIT
 */
class GlowOverlayFilter extends AbstractBaseFilter {

  /** @override */
  padding = 6;

  /**
   * The inner strength of the glow.
   * @type {number}
   */
  innerStrength = 3;

  /**
   * The outer strength of the glow.
   * @type {number}
   */
  outerStrength = 3;

  /**
   * Should this filter auto-animate?
   * @type {boolean}
   */
  animated = true;

  /** @inheritdoc */
  static defaultUniforms = {
    distance: 10,
    glowColor: [1, 1, 1, 1],
    quality: 0.1,
    time: 0,
    knockout: true,
    alpha: 1
  };

  /**
   * Dynamically create the fragment shader used for filters of this type.
   * @param {number} quality
   * @param {number} distance
   * @returns {string}
   */
  static createFragmentShader(quality, distance) {
    return `
    precision mediump float;
    varying vec2 vTextureCoord;
    varying vec4 vColor;
  
    uniform sampler2D uSampler;
    uniform float innerStrength;
    uniform float outerStrength;
    uniform float alpha;
    uniform vec4 glowColor;
    uniform vec4 inputSize;
    uniform vec4 inputClamp;
    uniform bool knockout;
  
    const float PI = 3.14159265358979323846264;
    const float DIST = ${distance.toFixed(0)}.0;
    const float ANGLE_STEP_SIZE = min(${(1 / quality / distance).toFixed(7)}, PI * 2.0);
    const float ANGLE_STEP_NUM = ceil(PI * 2.0 / ANGLE_STEP_SIZE);
    const float MAX_TOTAL_ALPHA = ANGLE_STEP_NUM * DIST * (DIST + 1.0) / 2.0;
  
    float getClip(in vec2 uv) {
      return step(3.5,
       step(inputClamp.x, uv.x) +
       step(inputClamp.y, uv.y) +
       step(uv.x, inputClamp.z) +
       step(uv.y, inputClamp.w));
    }
  
    void main(void) {
      vec2 px = inputSize.zw;
      float totalAlpha = 0.0;
      vec2 direction;
      vec2 displaced;
      vec4 curColor;
  
      for (float angle = 0.0; angle < PI * 2.0; angle += ANGLE_STEP_SIZE) {
       direction = vec2(cos(angle), sin(angle)) * px;
       for (float curDistance = 0.0; curDistance < DIST; curDistance++) {
         displaced = vTextureCoord + direction * (curDistance + 1.0);
         curColor = texture2D(uSampler, displaced) * getClip(displaced);
         totalAlpha += (DIST - curDistance) * (smoothstep(0.5, 1.0, curColor.a));
       }
      }
  
      curColor = texture2D(uSampler, vTextureCoord);
      float alphaRatio = (totalAlpha / MAX_TOTAL_ALPHA);
      
      float innerGlowAlpha = (1.0 - alphaRatio) * innerStrength * smoothstep(0.6, 1.0, curColor.a);
      float innerGlowStrength = min(1.0, innerGlowAlpha);
      
      vec4 innerColor = mix(curColor, glowColor, innerGlowStrength);

      float outerGlowAlpha = alphaRatio * outerStrength * (1.0 - smoothstep(0.35, 1.0, curColor.a));
      float outerGlowStrength = min(1.0 - innerColor.a, outerGlowAlpha);
      vec4 outerGlowColor = outerGlowStrength * glowColor.rgba;
      
      if ( knockout ) {
        float resultAlpha = outerGlowAlpha + innerGlowAlpha;
        gl_FragColor = mix(vec4(glowColor.rgb * resultAlpha, resultAlpha), vec4(0.0), curColor.a);
      }
      else {
        vec4 outerGlowColor = outerGlowStrength * glowColor.rgba * alpha;
        gl_FragColor = innerColor + outerGlowColor;
      }
    }`;
  }

  /** @inheritdoc */
  static vertexShader = `
  precision mediump float;
  attribute vec2 aVertexPosition;
  uniform mat3 projectionMatrix;
  uniform vec4 inputSize;
  uniform vec4 outputFrame;
  varying vec2 vTextureCoord;

  void main(void) {
      vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.0)) + outputFrame.xy;
      gl_Position = vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);
      vTextureCoord = aVertexPosition * (outputFrame.zw * inputSize.zw);
  }`;

  /** @inheritdoc */
  static create(initialUniforms={}) {
    const uniforms = {...this.defaultUniforms, ...initialUniforms};
    const fragmentShader = this.createFragmentShader(uniforms.quality, uniforms.distance);
    return new this(this.vertexShader, fragmentShader, uniforms);
  }

  /* -------------------------------------------- */

  /** @override */
  apply(filterManager, input, output, clear) {
    let strength = canvas.stage.worldTransform.d;
    if ( this.animated && !canvas.photosensitiveMode ) {
      const time = canvas.app.ticker.lastTime;
      strength *= Math.oscillation(0.5, 2.0, time, 2000);
    }
    this.uniforms.outerStrength = this.outerStrength * strength;
    this.uniforms.innerStrength = this.innerStrength * strength;
    filterManager.applyFilter(this, input, output, clear);
  }
}

/**
 * Invisibility effect filter for placeables.
 */
class InvisibilityFilter extends AbstractBaseFilter {

  /** @override */
  static fragmentShader = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;
    uniform vec3 color;
    uniform sampler2D uSampler;
    varying vec2 vTextureCoord;
    
    ${this.CONSTANTS}
    ${this.PERCEIVED_BRIGHTNESS}
          
    void main() {
      vec4 baseColor = texture2D(uSampler, vTextureCoord);
      
      // Unmultiply rgb with alpha channel
      if ( baseColor.a > 0.0 ) baseColor.rgb /= baseColor.a;
        
      // Computing halo
      float lum = perceivedBrightness(baseColor.rgb);
      vec3 haloColor = vec3(lum) * color * 2.0;
  
      // Construct final image
      gl_FragColor = vec4(haloColor, 1.0) * 0.5 * baseColor.a;
    }
    `;

  /** @override */
  static defaultUniforms = {
    uSampler: null,
    color: [0.5, 1, 1]
  };
}

/**
 * A filter which implements an outline.
 * Inspired from https://github.com/pixijs/filters/tree/main/filters/outline
 * @license MIT
 */
class OutlineOverlayFilter extends AbstractBaseFilter {
  /** @override */
  padding = 3;

  /** @override */
  autoFit = false;

  /**
   * If the filter is animated or not.
   * @type {boolean}
   */
  animated = true;

  /** @inheritdoc */
  static defaultUniforms = {
    outlineColor: [1, 1, 1, 1],
    thickness: [1, 1],
    alphaThreshold: 0.60,
    knockout: true,
    wave: false
  };

  /** @override */
  static vertexShader = `
  attribute vec2 aVertexPosition;

  uniform mat3 projectionMatrix;
  uniform vec2 screenDimensions;
  uniform vec4 inputSize;
  uniform vec4 outputFrame;

  varying vec2 vTextureCoord;
  varying vec2 vFilterCoord;

  vec4 filterVertexPosition( void ) {
      vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;
      return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0., 1.);
  }

  // getting normalized coord for the tile texture
  vec2 filterTextureCoord( void ) {
      return aVertexPosition * (outputFrame.zw * inputSize.zw);
  }

  // getting normalized coord for a screen sized mask render texture
  vec2 filterCoord( in vec2 textureCoord ) {
    return textureCoord * inputSize.xy / outputFrame.zw;
  }

  void main() {
    vTextureCoord = filterTextureCoord();
    vFilterCoord = filterCoord(vTextureCoord);
    gl_Position = filterVertexPosition();
  }`;


  /**
   * Dynamically create the fragment shader used for filters of this type.
   * @returns {string}
   */
  static createFragmentShader() {
    return `
    varying vec2 vTextureCoord;
    varying vec2 vFilterCoord;
    uniform sampler2D uSampler;
    
    uniform vec2 thickness;
    uniform vec4 outlineColor;
    uniform vec4 filterClamp;
    uniform float alphaThreshold;
    uniform float time;
    uniform bool knockout;
    uniform bool wave;
    
    ${this.CONSTANTS}
    ${this.WAVE()}
    
    void main(void) {
        float dist = distance(vFilterCoord, vec2(0.5)) * 2.0;
        vec4 ownColor = texture2D(uSampler, vTextureCoord);
        vec4 wColor = wave ? outlineColor * 
                             wcos(0.0, 1.0, dist * 75.0, 
                                  -time * 0.01 + 3.0 * dot(vec4(1.0), ownColor)) 
                             * 0.33 * (1.0 - dist) : vec4(0.0);
        float texAlpha = smoothstep(alphaThreshold, 1.0, ownColor.a);
        vec4 curColor;
        float maxAlpha = 0.;
        vec2 displaced;
        for ( float angle = 0.0; angle <= TWOPI; angle += ${this.#quality.toFixed(7)} ) {
            displaced.x = vTextureCoord.x + thickness.x * cos(angle);
            displaced.y = vTextureCoord.y + thickness.y * sin(angle);
            curColor = texture2D(uSampler, clamp(displaced, filterClamp.xy, filterClamp.zw));
            curColor.a = clamp((curColor.a - 0.6) * 2.5, 0.0, 1.0);
            maxAlpha = max(maxAlpha, curColor.a);
        }
        float resultAlpha = max(maxAlpha, texAlpha);
        vec3 result = ((knockout ? vec3(0.0) : ownColor.rgb) + outlineColor.rgb * (1.0 - texAlpha)) * resultAlpha;
        gl_FragColor = mix(vec4(result, resultAlpha), wColor, texAlpha);
    }
    `;
  }

  /* -------------------------------------------- */

  /**
   * Quality of the outline according to performance mode.
   * @returns {number}
   */
  static get #quality() {
    switch ( canvas.performance.mode ) {
      case CONST.CANVAS_PERFORMANCE_MODES.LOW:
        return (Math.PI * 2) / 10;
      case CONST.CANVAS_PERFORMANCE_MODES.MED:
        return (Math.PI * 2) / 20;
      default:
        return (Math.PI * 2) / 30;
    }
  }

  /* -------------------------------------------- */

  /**
   * The thickness of the outline.
   * @type {number}
   */
  get thickness() {
    return this.#thickness;
  }

  set thickness(value) {
    this.#thickness = value;
    this.padding = value * 1.5;
  }

  #thickness = 3;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static create(initialUniforms={}) {
    const uniforms = {...this.defaultUniforms, ...initialUniforms};
    return new this(this.vertexShader, this.createFragmentShader(), uniforms);
  }

  /* -------------------------------------------- */

  /** @override */
  apply(filterManager, input, output, clear) {
    if ( canvas.photosensitiveMode ) this.uniforms.wave = false;
    let time = 0;
    let thickness = this.#thickness * canvas.stage.scale.x;
    if ( this.animated && !canvas.photosensitiveMode ) {
      time = canvas.app.ticker.lastTime;
      thickness *= Math.oscillation(0.75, 1.25, time, 1500);
    }
    this.uniforms.time = time;
    this.uniforms.thickness[0] = thickness / input._frame.width;
    this.uniforms.thickness[1] = thickness / input._frame.height;
    filterManager.applyFilter(this, input, output, clear);
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @deprecated since v12
   * @ignore
   */
  get animate() {
    const msg = "OutlineOverlayFilter#animate is deprecated in favor of OutlineOverlayFilter#animated.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    return this.animated;
  }

  /**
   * @deprecated since v12
   * @ignore
   */
  set animate(v) {
    const msg = "OutlineOverlayFilter#animate is deprecated in favor of OutlineOverlayFilter#animated.";
    foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
    this.animated = v;
  }
}

/**
 * A filter specialized for transition effects between a source object and a target texture.
 */
class TextureTransitionFilter extends AbstractBaseFilter {

  /**
   * If this filter requires padding (according to type)
   * @type {boolean}
   */
  #requirePadding = false;

  /* -------------------------------------------- */

  /**
   * Transition types for this shader.
   * @enum {string}
   */
  static get TYPES() {
    return TextureTransitionFilter.#TYPES;
  }

  static #TYPES = Object.freeze({
    FADE: "fade",
    SWIRL: "swirl",
    WATER_DROP: "waterDrop",
    MORPH: "morph",
    CROSSHATCH: "crosshatch",
    WIND: "wind",
    WAVES: "waves",
    WHITE_NOISE: "whiteNoise",
    HOLOGRAM: "hologram",
    HOLE: "hole",
    HOLE_SWIRL: "holeSwirl",
    GLITCH: "glitch",
    DOTS: "dots"
  });

  /* -------------------------------------------- */

  /**
   * Maps the type number to its string.
   * @type {ReadonlyArray<string>}
   */
  static #TYPE_NUMBER_TO_STRING = Object.freeze(Object.values(this.TYPES));

  /* -------------------------------------------- */

  /**
   * Maps the type string to its number.
   * @type {Readonly<{[type: string]: number}>}
   */
  static #TYPE_STRING_TO_NUMBER = Object.freeze(Object.fromEntries(this.#TYPE_NUMBER_TO_STRING.map((t, i) => [t, i])));

  /* -------------------------------------------- */

  /**
   * Types that requires padding
   * @type {ReadonlyArray<string>}
   */
  static #PADDED_TYPES = Object.freeze([
    this.#TYPES.SWIRL,
    this.#TYPES.WATER_DROP,
    this.#TYPES.WAVES,
    this.#TYPES.HOLOGRAM
  ]);

  /* -------------------------------------------- */

  /**
   * The transition type (see {@link TextureTransitionFilter.TYPES}).
   * @type {string}
   * @defaultValue TextureTransitionFilter.TYPES.FADE
   */
  get type() {
    return TextureTransitionFilter.#TYPE_NUMBER_TO_STRING[this.uniforms.type];
  }

  set type(type) {
    if ( !(type in TextureTransitionFilter.#TYPE_STRING_TO_NUMBER) ) throw new Error("Invalid texture transition type");
    this.uniforms.type = TextureTransitionFilter.#TYPE_STRING_TO_NUMBER[type];
    this.#requirePadding = TextureTransitionFilter.#PADDED_TYPES.includes(type);
  }

  /* -------------------------------------------- */

  /**
   * Sampler target for this filter.
   * @param {PIXI.Texture} targetTexture
   */
  set targetTexture(targetTexture) {
    if ( !targetTexture.uvMatrix ) {
      targetTexture.uvMatrix = new PIXI.TextureMatrix(targetTexture, 0.0);
      targetTexture.uvMatrix.update();
    }
    this.uniforms.targetTexture = targetTexture;
    this.uniforms.targetUVMatrix = targetTexture.uvMatrix.mapCoord.toArray(true);
  }

  /* -------------------------------------------- */

  /**
   * Animate a transition from a subject SpriteMesh/PIXI.Sprite to a given texture.
   * @param {PIXI.Sprite|SpriteMesh} subject                           The source mesh/sprite to apply a transition.
   * @param {PIXI.Texture} texture                                     The target texture.
   * @param {object} [options]
   * @param {string} [options.type=TYPES.FADE]                         The transition type (default to FADE.)
   * @param {string|symbol} [options.name]                             The name of the {@link CanvasAnimation}.
   * @param {number} [options.duration=1000]                           The animation duration
   * @param {Function|string} [options.easing]                         The easing function of the animation
   * @returns {Promise<boolean>}   A Promise which resolves to true once the animation has concluded
   *                               or false if the animation was prematurely terminated
   */
  static async animate(subject, texture, {type=this.TYPES.FADE, name, duration, easing}={}) {
    if ( !((subject instanceof SpriteMesh) || (subject instanceof PIXI.Sprite)) ) {
      throw new Error("The subject must be a subclass of SpriteMesh or PIXI.Sprite");
    }
    if ( !(texture instanceof PIXI.Texture) ) {
      throw new Error("The target texture must be a subclass of PIXI.Texture");
    }

    // Create the filter and activate it on the subject
    const filter = this.create();
    filter.type = type;
    filter.targetTexture = texture;
    subject.filters ??= [];
    subject.filters.unshift(filter);

    // Create the animation
    const promise = CanvasAnimation.animate([{attribute: "progress", parent: filter.uniforms, to: 1}],
      {name, duration, easing, context: subject});

    // Replace the texture if the animation was completed
    promise.then(completed => {
      if ( completed ) subject.texture = texture;
    });

    // Remove the transition filter from the target once the animation was completed or terminated
    promise.finally(() => {
      subject.filters?.findSplice(f => f === filter);
    });
    return promise;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defaultUniforms = {
    tintAlpha: [1, 1, 1, 1],
    targetTexture: null,
    progress: 0,
    rotation: 0,
    anchor: {x: 0.5, y: 0.5},
    type: 1,
    filterMatrix: new PIXI.Matrix(),
    filterMatrixInverse: new PIXI.Matrix(),
    targetUVMatrix: new PIXI.Matrix()
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static vertexShader = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;

    attribute vec2 aVertexPosition;

    uniform mat3 projectionMatrix;
    uniform mat3 filterMatrix;
    uniform vec4 inputSize;
    uniform vec4 outputFrame;

    varying vec2 vTextureCoord;
    varying vec2 vFilterCoord;

    vec4 filterVertexPosition() {
      vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;
      return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);
    }

    vec2 filterTextureCoord() {
      return aVertexPosition * (outputFrame.zw * inputSize.zw);
    }

    void main() {
      gl_Position = filterVertexPosition();
      vTextureCoord = filterTextureCoord();
      vFilterCoord = (filterMatrix * vec3(vTextureCoord, 1.0)).xy;
    }
  `;

  /* -------------------------------------------- */

  /** @inheritDoc */
  static fragmentShader = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;
    ${this.CONSTANTS}
    ${this.PRNG}
    uniform float progress;
    uniform float rotation;
    uniform vec2 anchor;
    uniform int type;
    uniform sampler2D uSampler;
    uniform sampler2D targetTexture;
    uniform vec4 tintAlpha;
    uniform mat3 filterMatrixInverse;
    uniform mat3 targetUVMatrix;

    varying vec2 vTextureCoord;
    varying vec2 vFilterCoord;

    /* -------------------------------------------- */
    /*  UV Mapping Functions                        */
    /* -------------------------------------------- */

    /* Map filter coord to source texture coord */
    vec2 mapFuv2Suv(in vec2 uv) {
      return (filterMatrixInverse * vec3(uv, 1.0)).xy;
    }

    /* Map filter coord to target texture coord */
    vec2 mapFuv2Tuv(in vec2 uv) {
      return (targetUVMatrix * vec3(uv, 1.0)).xy;
    }

    /* -------------------------------------------- */
    /*  Clipping Functions                          */
    /* -------------------------------------------- */

    float getClip(in vec2 uv) {
      return step(3.5,
         step(0.0, uv.x) +
         step(0.0, uv.y) +
         step(uv.x, 1.0) +
         step(uv.y, 1.0));
    }

    /* -------------------------------------------- */
    /*  Texture Functions                           */
    /* -------------------------------------------- */

    vec4 colorFromSource(in vec2 uv) {
      return texture2D(uSampler, uv);
    }

    vec4 colorFromTarget(in vec2 uv) {
      return texture2D(targetTexture, mapFuv2Tuv(uv))
                       * getClip(uv);
    }

    /* -------------------------------------------- */
    /*  Simple transition                           */
    /* -------------------------------------------- */

    vec4 transition() {
      return mix(
        colorFromSource(vTextureCoord),
        colorFromTarget(vFilterCoord),
        progress
      );
    }
    
    /* -------------------------------------------- */
    /*  Morphing                                    */
    /* -------------------------------------------- */

    vec4 morph() {
      vec4 ca = colorFromSource(vTextureCoord);
      vec4 cb = colorFromTarget(vFilterCoord);
      float a = mix(ca.a, cb.a, progress);

      vec2 oa = (((ca.rg + ca.b) * 0.5) * 2.0 - 1.0);
      vec2 ob = (((cb.rg + cb.b) * 0.5) * 2.0 - 1.0);
      vec2 oc = mix(oa, ob, 0.5) * 0.2;

      float w0 = progress;
      float w1 = 1.0 - w0;
      return mix(colorFromSource(mapFuv2Suv(vFilterCoord + oc * w0)),
                 colorFromTarget(vFilterCoord - oc * w1),
                 progress) * smoothstep(0.0, 0.5, a);
    }

    /* -------------------------------------------- */
    /*  Water Drop                                  */
    /* -------------------------------------------- */

    vec4 drop() {
      vec2 dir = vFilterCoord - 0.5;
      float dist = length(dir);
      float da = clamp(1.6 - distance(vec2(0.0), vFilterCoord * 2.0 - 1.0), 0.0, 1.6) / 1.6;
      vec2 offset = mix(vec2(0.0),
                        dir * sin(dist * 35.0 - progress * 35.0),
                        min(min(progress, 1.0 - progress) * 2.0, da));
      return mix(colorFromSource(mapFuv2Suv(vFilterCoord + offset)),
                 colorFromTarget(vFilterCoord + offset),
                 progress);
    }

    /* -------------------------------------------- */
    /*  Waves effect                                */
    /* -------------------------------------------- */

    vec2 offset(in float progress, in float x, in float str) {
      float p = smoothstep(0.0, 1.0, min(progress, 1.0 - progress) * 2.0);
      float shifty = str * p * cos(30.0 * (progress + x));
      return vec2(0.0, shifty);
    }

    vec4 wavy() {
      vec4 ca = colorFromSource(vTextureCoord);
      vec4 cb = colorFromTarget(vFilterCoord);
      float a = mix(ca.a, cb.a, progress);
      vec2 shift = vFilterCoord + offset(progress, vFilterCoord.x, 0.20);
      vec4 c0 = colorFromSource(mapFuv2Suv(shift));
      vec4 c1 = colorFromTarget(shift);
      return mix(c0, c1, progress);
    }

    /* -------------------------------------------- */
    /*  White Noise                                 */
    /* -------------------------------------------- */

    float noise(vec2 co) {
      float a = 12.9898;
      float b = 78.233;
      float c = 43758.5453;
      float dt = dot(co.xy * progress, vec2(a, b));
      float sn = mod(dt, 3.14);
      return fract(sin(sn) * c);
    }

    vec4 whitenoise() {
      const float m = (1.0 / 0.15);
      vec4 noise = vec4(vec3(noise(vFilterCoord)), 1.0);
      vec4 cr = morph();
      float alpha = smoothstep(0.0, 0.75, cr.a);
      return mix(cr, noise * alpha, smoothstep(0.0, 0.1, min(progress, 1.0 - progress)));
    }

    /* -------------------------------------------- */
    /*  Swirling                                    */
    /* -------------------------------------------- */

    vec2 sphagetization(inout vec2 uv, in float p) {
      const float r = 1.0;
      float dist = length(uv);
      if ( dist < r ) {
        float percent = r - dist;
        float a = (p <= 0.5) ? mix(0.0, 1.0, p / 0.5) : mix(1.0, 0.0, (p - 0.5) / 0.5);
        float tt = percent * percent * a * 8.0 * PI;
        float s = sin(tt);
        float c = cos(tt);
        uv = vec2(dot(uv, vec2(c, -s)), dot(uv, vec2(s, c)));
      }
      return uv;
    }

    vec4 swirl() {
      float p = progress;
      vec2 uv = vFilterCoord - 0.5;
      uv = sphagetization(uv, p);
      uv += 0.5;
      vec4 c0 = colorFromSource(mapFuv2Suv(uv));
      vec4 c1 = colorFromTarget(uv);
      return mix(c0, c1, p) * smoothstep(0.0, 0.5, mix(c0.a, c1.a, progress));
    }

    /* -------------------------------------------- */
    /*  Cross Hatch                                 */
    /* -------------------------------------------- */

    vec4 crosshatch() {
      float dist = distance(vec2(0.5), vFilterCoord) / 3.0;
      float r = progress - min(random(vec2(vFilterCoord.y, 0.0)),
                               random(vec2(0.0, vFilterCoord.x)));
      return mix(colorFromSource(vTextureCoord),
                 colorFromTarget(vFilterCoord),
                 mix(0.0,
                     mix(step(dist, r),
                     1.0,
                     smoothstep(0.7, 1.0, progress)),
                 smoothstep(0.0, 0.3, progress)));
    }

    /* -------------------------------------------- */
    /*  Lateral Wind                                */
    /* -------------------------------------------- */

    vec4 wind() {
      const float s = 0.2;
      float r = random(vec2(0, vFilterCoord.y));
      float p = smoothstep(0.0, -s, vFilterCoord.x * (1.0 - s) + s * r - (progress * (1.0 + s)));
      return mix(
        colorFromSource(vTextureCoord),
        colorFromTarget(vFilterCoord),
        p
      );
    }

    /* -------------------------------------------- */
    /*  Holographic effect                          */
    /* -------------------------------------------- */

    vec2 roffset(in float progress, in float x, in float theta, in float str) {
      float shifty = (1.0 - progress) * str * progress * cos(10.0 * (progress + x));
      return vec2(0, shifty);
    }

    vec4 hologram() {
      float cosProg = 0.5 * (cos(2.0 * PI * progress) + 1.0);
      vec2 os = roffset(progress, vFilterCoord.x, 0.0, 0.24);
      vec4 fscol = colorFromSource(mapFuv2Suv(vFilterCoord + os));
      vec4 ftcol = colorFromTarget(vFilterCoord + os);

      float scintensity = max(max(fscol.r, fscol.g), fscol.b);
      float tcintensity = max(max(ftcol.r, ftcol.g), ftcol.b);

      vec4 tscol = vec4(0.0, fscol.g * 3.0, 0.0, 1.0) * scintensity;
      vec4 ttcol = vec4(ftcol.r * 3.0, 0.0, 0.0, 1.0) * tcintensity;

      vec4 iscol = vec4(0.0, fscol.g * 3.0, fscol.b * 3.0, 1.0) * scintensity;
      vec4 itcol = vec4(ftcol.r * 3.0, 0.0, ftcol.b * 3.0, 1.0) * tcintensity;

      vec4 smix = mix(mix(fscol, tscol, progress), iscol, 1.0 - cosProg);
      vec4 tmix = mix(mix(ftcol, ttcol, 1.0 - progress), itcol, 1.0 - cosProg);
      return mix(smix, tmix, progress);
    }

    /* -------------------------------------------- */
    /*  Hole effect                                 */
    /* -------------------------------------------- */

    vec4 hole() {
      vec2 uv = vFilterCoord;
      float s = smoothstep(0.0, 1.0, min(progress, 1.0 - progress) * 2.0);
      uv -= 0.5;
      uv *= (1.0 + s * 30.0);
      uv += 0.5;
      float clip = getClip(uv);

      vec4 sc = colorFromSource(mapFuv2Suv(uv)) * clip;
      vec4 tc = colorFromTarget(uv);
      return mix(sc, tc, smoothstep(0.4, 0.6, progress));
    }

    /* -------------------------------------------- */
    /*  Hole Swirl effect                           */
    /* -------------------------------------------- */

    vec4 holeSwirl() {
      vec2 uv = vFilterCoord;
      vec4 deepBlack = vec4(vec3(0.25), 1.0);
      float mp = min(progress, 1.0 - progress) * 2.0;
      float sw = smoothstep(0.0, 1.0, mp);
      uv -= 0.5;
      uv *= (1.0 + sw * 15.0);
      uv = sphagetization(uv, progress);
      uv += 0.5;
      float clip = getClip(uv);

      vec4 sc = colorFromSource(mapFuv2Suv(uv)) * clip;
      vec4 tc = colorFromTarget(uv);

      float sv = smoothstep(0.0, 0.35, mp);
      return mix(mix(sc, sc * deepBlack, sv), mix(tc, tc * deepBlack, sv), smoothstep(0.4, 0.6, progress));
    }
    
    /* -------------------------------------------- */
    /*  Glitch                                      */
    /* -------------------------------------------- */

    vec4 glitch() {
      // Precompute constant values
      vec2 inv64 = vec2(1.0 / 64.0);
      vec2 uvOffset = floor(vec2(progress) * vec2(1200.0, 3500.0)) * inv64;
      vec2 halfVec = vec2(0.5);
  
      // Compute block and normalized UV coordinates
      vec2 blk = floor(vFilterCoord / vec2(16.0));
      vec2 uvn = blk * inv64 + uvOffset;
  
      // Compute distortion only if progress > 0.0
      vec2 dist = progress > 0.0 
                  ? (fract(uvn) - halfVec) * 0.3 * (1.0 - progress) 
                  : vec2(0.0);
  
      // Precompute distorted coordinates
      vec2 coords[4];
      for ( int i = 0; i < 4; ++i ) {
        coords[i] = vFilterCoord + dist * (0.4 - 0.1 * float(i));
      }
  
      // Fetch colors and mix them
      vec4 colorResult;
      for ( int i = 0; i < 4; ++i ) {
        vec4 colorSrc = colorFromSource(mapFuv2Suv(coords[i]));
        vec4 colorTgt = colorFromTarget(coords[i]);
        colorResult[i] = mix(colorSrc[i], colorTgt[i], progress);
      }
      return colorResult;
    }
    
    /* -------------------------------------------- */
    /*  Dots                                        */
    /* -------------------------------------------- */
    
    vec4 dots() {
      vec2 halfVec = vec2(0.5);
      float distToCenter = distance(vFilterCoord, halfVec);
      float threshold = pow(progress, 3.0) / distToCenter;
      float distToDot = distance(fract(vFilterCoord * 30.0), halfVec);
  
      // Compute the factor to mix colors based on the threshold comparison
      float isTargetFactor = step(distToDot, threshold);
      vec4 targetColor = colorFromTarget(vFilterCoord);
      vec4 sourceColor = colorFromSource(vTextureCoord);
      return mix(sourceColor, targetColor, isTargetFactor);
    }

    /* -------------------------------------------- */
    /*  Main Program                                */
    /* -------------------------------------------- */

    void main() {
      vec4 result;
      if ( type == 1 ) {
        result = swirl();
      } else if ( type == 2 ) {
        result = drop();
      } else if ( type == 3 ) {
        result = morph();
      } else if ( type == 4 ) {
        result = crosshatch();
      } else if ( type == 5 ) {
        result = wind();
      } else if ( type == 6 ) {
        result = wavy();
      } else if ( type == 7 ) {
        result = whitenoise();
      } else if ( type == 8 ) {
        result = hologram();
      } else if ( type == 9 ) {
        result = hole();
      } else if ( type == 10 ) {
        result = holeSwirl();
      } else if ( type == 11 ) {
        result = glitch();
      } else if ( type == 12 ) {
        result = dots();
      } else {
        result = transition();
      }
      gl_FragColor = result * tintAlpha;
    }
  `;

  /* -------------------------------------------- */

  /** @inheritDoc */
  apply(filterManager, input, output, clear) {
    const filterMatrix = this.uniforms.filterMatrix;
    const {sourceFrame, destinationFrame, target} = filterManager.activeState;

    if ( this.#requirePadding ) {
      this.padding = Math.max(target.width, target.height) * 0.5 * canvas.stage.worldTransform.d;
    }
    else this.padding = 0;

    filterMatrix.set(destinationFrame.width, 0, 0, destinationFrame.height, sourceFrame.x, sourceFrame.y);
    const worldTransform = PIXI.Matrix.TEMP_MATRIX;
    const localBounds = target.getLocalBounds();

    worldTransform.copyFrom(target.transform.worldTransform);
    worldTransform.invert();
    filterMatrix.prepend(worldTransform);
    filterMatrix.translate(-localBounds.x, -localBounds.y);
    filterMatrix.scale(1.0 / localBounds.width, 1.0 / localBounds.height);

    const filterMatrixInverse = this.uniforms.filterMatrixInverse;
    filterMatrixInverse.copyFrom(filterMatrix);
    filterMatrixInverse.invert();
    filterManager.applyFilter(this, input, output, clear);
  }
}

/**
 * Apply visibility coloration according to the baseLine color.
 * Uses very lightweight gaussian vertical and horizontal blur filter passes.
 */
class VisibilityFilter extends AbstractBaseMaskFilter {
  constructor(...args) {
    super(...args);

    // Handling inner blur filters configuration
    const b = canvas.blur;
    if ( b.enabled ) {
      const resolution = PIXI.Filter.defaultResolution;
      this.#blurXFilter = new b.blurPassClass(true, b.strength, b.passes, resolution, b.kernels);
      this.#blurYFilter = new b.blurPassClass(false, b.strength, b.passes, resolution, b.kernels);
    }

    // Handling fog overlay texture matrix
    this.#overlayTex = this.uniforms.overlayTexture;
    if ( this.#overlayTex && !this.#overlayTex.uvMatrix ) {
      this.#overlayTex.uvMatrix = new PIXI.TextureMatrix(this.#overlayTex.uvMatrix, 0.0);
    }
  }

  /**
   * Horizontal inner blur filter
   * @type {AlphaBlurFilterPass}
   */
  #blurXFilter;

  /**
   * Vertical inner blur filter
   * @type {AlphaBlurFilterPass}
   */
  #blurYFilter;

  /**
   * Optional fog overlay texture
   * @type {PIXI.Texture|undefined}
   */
  #overlayTex;

  /** @override */
  static defaultUniforms = {
    exploredColor: [1, 1, 1],
    unexploredColor: [0, 0, 0],
    screenDimensions: [1, 1],
    visionTexture: null,
    primaryTexture: null,
    overlayTexture: null,
    overlayMatrix: new PIXI.Matrix(),
    hasOverlayTexture: false
  };

  /** @override */
  static create(initialUniforms={}, options={}) {
    const uniforms = {...this.defaultUniforms, ...initialUniforms};
    return new this(this.vertexShader, this.fragmentShader(options), uniforms);
  }

  static vertexShader = `
  attribute vec2 aVertexPosition;
  uniform mat3 projectionMatrix;
  uniform mat3 overlayMatrix;
  varying vec2 vTextureCoord;
  varying vec2 vMaskTextureCoord;
  varying vec2 vOverlayCoord;
  varying vec2 vOverlayTilingCoord;
  uniform vec4 inputSize;
  uniform vec4 outputFrame;
  uniform vec4 dimensions;
  uniform vec2 screenDimensions;
  uniform bool hasOverlayTexture;

  vec4 filterVertexPosition( void ) {
    vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;
    return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0.0, 1.0);
  }

  vec2 filterTextureCoord( void ) {
    return aVertexPosition * (outputFrame.zw * inputSize.zw);
  }
  
  vec2 overlayTilingTextureCoord( void ) {
    if ( hasOverlayTexture ) return vOverlayCoord * (dimensions.xy / dimensions.zw);
    return vOverlayCoord;
  }
  
  // getting normalized coord for a screen sized mask render texture
  vec2 filterMaskTextureCoord( in vec2 textureCoord ) {
    return (textureCoord * inputSize.xy + outputFrame.xy) / screenDimensions;
  }

  void main(void) {
    gl_Position = filterVertexPosition();
    vTextureCoord = filterTextureCoord();
    vMaskTextureCoord = filterMaskTextureCoord(vTextureCoord);
    vOverlayCoord = (overlayMatrix * vec3(vTextureCoord, 1.0)).xy;
    vOverlayTilingCoord = overlayTilingTextureCoord();
  }`;

  /** @override */
  static fragmentShader(options) { return `
    varying vec2 vTextureCoord;
    varying vec2 vMaskTextureCoord;
    varying vec2 vOverlayCoord;
    varying vec2 vOverlayTilingCoord;
    uniform sampler2D uSampler;
    uniform sampler2D primaryTexture;
    uniform sampler2D overlayTexture;
    uniform vec3 unexploredColor;
    uniform vec3 backgroundColor;
    uniform bool hasOverlayTexture;
    ${options.persistentVision ? ``
    : `uniform sampler2D visionTexture;
     uniform vec3 exploredColor;`}
    ${this.CONSTANTS}
    ${this.PERCEIVED_BRIGHTNESS}
    
    // To check if we are out of the bound
    float getClip(in vec2 uv) {
      return step(3.5,
         step(0.0, uv.x) +
         step(0.0, uv.y) +
         step(uv.x, 1.0) +
         step(uv.y, 1.0));
    }
    
    // Unpremultiply fog texture
    vec4 unPremultiply(in vec4 pix) {
      if ( !hasOverlayTexture || (pix.a == 0.0) ) return pix;
      return vec4(pix.rgb / pix.a, pix.a);
    }
  
    void main() {
      float r = texture2D(uSampler, vTextureCoord).r;               // Revealed red channel from the filter texture
      ${options.persistentVision ? `` : `float v = texture2D(visionTexture, vMaskTextureCoord).r;`} // Vision red channel from the vision cached container
      vec4 baseColor = texture2D(primaryTexture, vMaskTextureCoord);// Primary cached container renderTexture color
      vec4 fogColor = hasOverlayTexture 
                      ? texture2D(overlayTexture, vOverlayTilingCoord) * getClip(vOverlayCoord)
                      : baseColor;      
      fogColor = unPremultiply(fogColor);
      
      // Compute fog exploration colors
      ${!options.persistentVision
    ? `float reflec = perceivedBrightness(baseColor.rgb);
      vec4 explored = vec4(min((exploredColor * reflec) + (baseColor.rgb * exploredColor), vec3(1.0)), 0.5);`
    : ``}
      vec4 unexplored = hasOverlayTexture
                        ? mix(vec4(unexploredColor, 1.0), vec4(fogColor.rgb * backgroundColor, 1.0), fogColor.a)
                        : vec4(unexploredColor, 1.0);
  
      // Mixing components to produce fog of war
      ${options.persistentVision
    ? `gl_FragColor = mix(unexplored, vec4(0.0), r);`
    : `vec4 fow = mix(unexplored, explored, max(r,v));
       gl_FragColor = mix(fow, vec4(0.0), v);`}
      
      // Output the result
      gl_FragColor.rgb *= gl_FragColor.a;
    }`
  }

  /**
   * Set the blur strength
   * @param {number} value    blur strength
   */
  set blur(value) {
    if ( this.#blurXFilter ) this.#blurXFilter.blur = this.#blurYFilter.blur = value;
  }

  get blur() {
    return this.#blurYFilter?.blur;
  }

  /** @override */
  apply(filterManager, input, output, clear) {
    this.calculateMatrix(filterManager);
    if ( canvas.blur.enabled ) {
      // Get temporary filter textures
      const firstRenderTarget = filterManager.getFilterTexture();
      // Apply inner filters
      this.state.blend = false;
      this.#blurXFilter.apply(filterManager, input, firstRenderTarget, PIXI.CLEAR_MODES.NONE);
      this.#blurYFilter.apply(filterManager, firstRenderTarget, input, PIXI.CLEAR_MODES.NONE);
      this.state.blend = true;
      // Inform PIXI that temporary filter textures are not more necessary
      filterManager.returnFilterTexture(firstRenderTarget);
    }
    // Apply visibility
    super.apply(filterManager, input, output, clear);
  }

  /**
   * Calculate the fog overlay sprite matrix.
   * @param {PIXI.FilterSystem} filterManager
   */
  calculateMatrix(filterManager) {
    if ( !this.uniforms.hasOverlayTexture || !this.#overlayTex ) return;
    if ( this.#overlayTex && !this.#overlayTex.uvMatrix ) {
      this.#overlayTex.uvMatrix = new PIXI.TextureMatrix(this.#overlayTex.uvMatrix, 0.0);
    }
    this.#overlayTex.uvMatrix.update();
    const mat = filterManager.calculateSpriteMatrix(this.uniforms.overlayMatrix, canvas.visibility.visibilityOverlay);
    this.uniforms.overlayMatrix = mat.prepend(this.#overlayTex.uvMatrix.mapCoord);
  }
}

class VisionMaskFilter extends AbstractBaseMaskFilter {
  /** @override */
  static fragmentShader = `
    precision mediump float;
    varying vec2 vTextureCoord;
    varying vec2 vMaskTextureCoord;
    uniform sampler2D uSampler;
    uniform sampler2D uMaskSampler;
    void main() {
      float mask = texture2D(uMaskSampler, vMaskTextureCoord).r;
      gl_FragColor = texture2D(uSampler, vTextureCoord) * mask;
    }`;

  /** @override */
  static defaultUniforms = {
    uMaskSampler: null
  };

  /** @override */
  static create() {
    return super.create({
      uMaskSampler: canvas.masks.vision.renderTexture
    });
  }

  /**
   * Overridden as an alias for canvas.visibility.visible.
   * This property cannot be set.
   * @override
   */
  get enabled() {
    return canvas.visibility.visible;
  }

  set enabled(value) {}
}

/**
 * A minimalist filter (just used for blending)
 */
class VoidFilter extends AbstractBaseFilter {
  static fragmentShader = `
  varying vec2 vTextureCoord;
  uniform sampler2D uSampler;
  void main() {
    gl_FragColor = texture2D(uSampler, vTextureCoord);
  }`;
}

/**
 * The filter used by the weather layer to mask weather above occluded roofs.
 * @see {@link WeatherEffects}
 */
class WeatherOcclusionMaskFilter extends AbstractBaseMaskFilter {

  /**
   * Elevation of this weather occlusion mask filter.
   * @type {number}
   */
  elevation = Infinity;

  /** @override */
  static vertexShader = `
    attribute vec2 aVertexPosition;
  
    // Filter globals uniforms
    uniform mat3 projectionMatrix;
    uniform mat3 terrainUvMatrix;
    uniform vec4 inputSize;
    uniform vec4 outputFrame;
    
    // Needed to compute mask and terrain normalized coordinates
    uniform vec2 screenDimensions;
    
    // Needed for computing scene sized texture coordinates 
    uniform vec2 sceneAnchor;
    uniform vec2 sceneDimensions;
    uniform bool useTerrain;
  
    varying vec2 vTextureCoord;
    varying vec2 vMaskTextureCoord;
    varying vec2 vTerrainTextureCoord;
    varying vec2 vSceneCoord;
  
    vec4 filterVertexPosition( void ) {
        vec2 position = aVertexPosition * max(outputFrame.zw, vec2(0.)) + outputFrame.xy;
        return vec4((projectionMatrix * vec3(position, 1.0)).xy, 0., 1.);
    }
  
    // getting normalized coord for the tile texture
    vec2 filterTextureCoord( void ) {
        return aVertexPosition * (outputFrame.zw * inputSize.zw);
    }
  
    // getting normalized coord for a screen sized mask render texture
    vec2 filterMaskTextureCoord( void ) {
      return (aVertexPosition * outputFrame.zw + outputFrame.xy) / screenDimensions;
    }
    
    vec2 filterTerrainSceneCoord( in vec2 textureCoord ) {
      return (textureCoord - (sceneAnchor / screenDimensions)) * (screenDimensions / sceneDimensions);
    }
    
    // get normalized terrain texture coordinates
    vec2 filterTerrainTextureCoord( in vec2 sceneCoord ) {
      return (terrainUvMatrix * vec3(vSceneCoord, 1.0)).xy;
    }
  
    void main() {
      vTextureCoord = filterTextureCoord();
      if ( useTerrain ) {
        vSceneCoord = filterTerrainSceneCoord(vTextureCoord);
        vTerrainTextureCoord = filterTerrainTextureCoord(vSceneCoord);
      }
      vMaskTextureCoord = filterMaskTextureCoord();
      gl_Position = filterVertexPosition();
    }`;

  /** @override */
  static fragmentShader = ` 
    // Occlusion mask uniforms
    uniform bool useOcclusion;
    uniform sampler2D occlusionTexture;
    uniform bool reverseOcclusion;
    uniform vec4 occlusionWeights;
    
    // Terrain mask uniforms
    uniform bool useTerrain;
    uniform sampler2D terrainTexture;
    uniform bool reverseTerrain;
    uniform vec4 terrainWeights;
    
    // Other uniforms
    varying vec2 vTextureCoord;
    varying vec2 vMaskTextureCoord;
    varying vec2 vTerrainTextureCoord;
    varying vec2 vSceneCoord;
    uniform sampler2D uSampler;
    uniform float depthElevation;
    uniform highp mat3 terrainUvMatrix;
    
    // Clip the terrain texture if out of bounds
    float getTerrainClip(vec2 uv) {
      return step(3.5,
         step(0.0, uv.x) +
         step(0.0, uv.y) +
         step(uv.x, 1.0) +
         step(uv.y, 1.0));
    }
    
    void main() {     
      // Base mask value 
      float mask = 1.0;
      
      // Process the occlusion mask
      if ( useOcclusion ) {
        float oMask = step(depthElevation, (254.5 / 255.0) - dot(occlusionWeights, texture2D(occlusionTexture, vMaskTextureCoord)));
        if ( reverseOcclusion ) oMask = 1.0 - oMask;
        mask *= oMask;
      }
                    
      // Process the terrain mask 
      if ( useTerrain ) {
        float tMask = dot(terrainWeights, texture2D(terrainTexture, vTerrainTextureCoord));
        if ( reverseTerrain ) tMask = 1.0 - tMask;
        mask *= (tMask * getTerrainClip(vSceneCoord));
      }
      
      // Process filtering and apply mask value
      gl_FragColor = texture2D(uSampler, vTextureCoord) * mask;
    }`;

  /** @override */
  static defaultUniforms = {
    depthElevation: 0,
    useOcclusion: true,
    occlusionTexture: null,
    reverseOcclusion: false,
    occlusionWeights: [0, 0, 1, 0],
    useTerrain: false,
    terrainTexture: null,
    reverseTerrain: false,
    terrainWeights: [1, 0, 0, 0],
    sceneDimensions: [0, 0],
    sceneAnchor: [0, 0],
    terrainUvMatrix: new PIXI.Matrix()
  };

  /** @override */
  apply(filterManager, input, output, clear, currentState) {
    if ( this.uniforms.useTerrain ) {
      const wt = canvas.stage.worldTransform;
      const z = wt.d;
      const sceneDim = canvas.scene.dimensions;

      // Computing the scene anchor and scene dimensions for terrain texture coordinates
      this.uniforms.sceneAnchor[0] = wt.tx + (sceneDim.sceneX * z);
      this.uniforms.sceneAnchor[1] = wt.ty + (sceneDim.sceneY * z);
      this.uniforms.sceneDimensions[0] = sceneDim.sceneWidth * z;
      this.uniforms.sceneDimensions[1] = sceneDim.sceneHeight * z;
    }
    this.uniforms.depthElevation = canvas.masks.depth.mapElevation(this.elevation);
    return super.apply(filterManager, input, output, clear, currentState);
  }
}

/**
 * The grid shader used by {@link GridMesh}.
 */
class GridShader extends AbstractBaseShader {

  /**
   * The grid type uniform.
   * @type {string}
   */
  static TYPE_UNIFORM = `
    const int TYPE_SQUARE = ${CONST.GRID_TYPES.SQUARE};
    const int TYPE_HEXODDR = ${CONST.GRID_TYPES.HEXODDR};
    const int TYPE_HEXEVENR = ${CONST.GRID_TYPES.HEXEVENR};
    const int TYPE_HEXODDQ = ${CONST.GRID_TYPES.HEXODDQ};
    const int TYPE_HEXEVENQ = ${CONST.GRID_TYPES.HEXEVENQ};

    uniform lowp int type;

    #define TYPE_IS_SQUARE (type == TYPE_SQUARE)
    #define TYPE_IS_HEXAGONAL ((TYPE_HEXODDR <= type) && (type <= TYPE_HEXEVENQ))
    #define TYPE_IS_HEXAGONAL_COLUMNS ((type == TYPE_HEXODDQ) || (type == TYPE_HEXEVENQ))
    #define TYPE_IS_HEXAGONAL_ROWS ((type == TYPE_HEXODDR) || (type == TYPE_HEXEVENR))
    #define TYPE_IS_HEXAGONAL_EVEN ((type == TYPE_HEXEVENR) || (type == TYPE_HEXEVENQ))
    #define TYPE_IS_HEXAGONAL_ODD ((type == TYPE_HEXODDR) || (type == TYPE_HEXODDQ))
  `;

  /* -------------------------------------------- */

  /**
   * The grid thickness uniform.
   * @type {string}
   */
  static THICKNESS_UNIFORM = "uniform float thickness;";

  /* -------------------------------------------- */

  /**
   * The grid color uniform.
   * @type {string}
   */
  static COLOR_UNIFORM = "uniform vec4 color;";

  /* -------------------------------------------- */

  /**
   * The resolution (pixels per grid space units) uniform.
   * @type {string}
   */
  static RESOLUTION_UNIFORM = "uniform float resolution;";

  /* -------------------------------------------- */

  /**
   * The antialiased step function.
   * The edge and x values is given in grid space units.
   * @type {string}
   */
  static ANTIALIASED_STEP_FUNCTION = `
    #define ANTIALIASED_STEP_TEMPLATE(type) \
      type antialiasedStep(type edge, type x) { \
        return clamp(((x - edge) * resolution) + 0.5, type(0.0), type(1.0)); \
      }

    ANTIALIASED_STEP_TEMPLATE(float)
    ANTIALIASED_STEP_TEMPLATE(vec2)
    ANTIALIASED_STEP_TEMPLATE(vec3)
    ANTIALIASED_STEP_TEMPLATE(vec4)

    #undef ANTIALIASED_STEP_TEMPLATE
  `;

  /* -------------------------------------------- */

  /**
   * The line converage function, which returns the alpha value at a point with the given distance (in grid space units)
   * from an antialiased line (or point) with the given thickness (in grid space units).
   * @type {string}
   */
  static LINE_COVERAGE_FUNCTION = `
    float lineCoverage(float distance, float thickness, float alignment) {
      float alpha0 = antialiasedStep((0.0 - alignment) * thickness, distance);
      float alpha1 = antialiasedStep((1.0 - alignment) * thickness, distance);
      return alpha0 - alpha1;
    }

    float lineCoverage(float distance, float thickness) {
      return lineCoverage(distance, thickness, 0.5);
    }
  `;

  /* -------------------------------------------- */

  /**
   * Hexagonal functions conversion for between grid and cube space.
   * @type {string}
   */
  static HEXAGONAL_FUNCTIONS = `
    vec2 pointToCube(vec2 p) {
      float x = p.x;
      float y = p.y;
      float q;
      float r;
      float e = TYPE_IS_HEXAGONAL_EVEN ? 1.0 : 0.0;
      if ( TYPE_IS_HEXAGONAL_COLUMNS ) {
        q = ((2.0 * SQRT1_3) * x) - (2.0 / 3.0);
        r = (-0.5 * (q + e)) + y;
      } else {
        r = ((2.0 * SQRT1_3) * y) - (2.0 / 3.0);
        q = (-0.5 * (r + e)) + x;
      }
      return vec2(q, r);
    }

    vec2 cubeToPoint(vec2 a) {
      float q = a[0];
      float r = a[1];
      float x;
      float y;
      float e = TYPE_IS_HEXAGONAL_EVEN ? 1.0 : 0.0;
      if ( TYPE_IS_HEXAGONAL_COLUMNS ) {
        x = (SQRT3 / 2.0) * (q + (2.0 / 3.0));
        y = (0.5 * (q + e)) + r;
      } else {
        y = (SQRT3 / 2.0) * (r + (2.0 / 3.0));
        x = (0.5 * (r + e)) + q;
      }
      return vec2(x, y);
    }

    vec2 offsetToCube(vec2 o) {
      float i = o[0];
      float j = o[1];
      float q;
      float r;
      float e = TYPE_IS_HEXAGONAL_EVEN ? 1.0 : -1.0;
      if ( TYPE_IS_HEXAGONAL_COLUMNS ) {
        q = j;
        r = i - ((j + (e * mod(j, 2.0))) * 0.5);
      } else {
        q = j - ((i + (e * mod(i, 2.0))) * 0.5);
        r = i;
      }
      return vec2(q, r);
    }

    ivec2 offsetToCube(ivec2 o) {
      int i = o[0];
      int j = o[1];
      int q;
      int r;
      int e = TYPE_IS_HEXAGONAL_EVEN ? 1 : -1;
      if ( TYPE_IS_HEXAGONAL_COLUMNS ) {
        q = j;
        r = i - ((j + (e * (j & 1))) / 2);
      } else {
        q = j - ((i + (e * (i & 1))) / 2);
        r = i;
      }
      return ivec2(q, r);
    }

    vec2 cubeToOffset(vec2 a) {
      float q = a[0];
      float r = a[1];
      float i;
      float j;
      float e = TYPE_IS_HEXAGONAL_EVEN ? 1.0 : -1.0;
      if ( TYPE_IS_HEXAGONAL_COLUMNS ) {
          j = q;
          i = r + ((q + (e * mod(q, 2.0))) * 0.5);
      } else {
          i = r;
          j = q + ((r + (e * mod(r, 2.0))) * 0.5);
      }
      return vec2(i, j);
    }

    ivec2 cubeToOffset(ivec2 a) {
      int q = a[0];
      int r = a[1];
      int i;
      int j;
      int e = TYPE_IS_HEXAGONAL_EVEN ? 1 : -1;
      if ( TYPE_IS_HEXAGONAL_COLUMNS ) {
          j = q;
          i = r + ((q + (e * (q & 1))) / 2);
      } else {
          i = r;
          j = q + ((r + (e * (r & 1))) / 2);
      }
      return ivec2(i, j);
    }

    vec2 cubeRound(vec2 a) {
      float q = a[0];
      float r = a[1];
      float s = -q - r;
      float iq = floor(q + 0.5);
      float ir = floor(r + 0.5);
      float is = floor(s + 0.5);
      float dq = abs(iq - q);
      float dr = abs(ir - r);
      float ds = abs(is - s);
      if ( (dq > dr) && (dq > ds) ) {
        iq = -ir - is;
      } else if ( dr > ds ) {
        ir = -iq - is;
      } else {
        is = -iq - ir;
      }
      return vec2(iq, ir);
    }

    float cubeDistance(vec2 a, vec2 b) {
      vec2 c = b - a;
      float q = c[0];
      float r = c[1];
      return (abs(q) + abs(r) + abs(q + r)) * 0.5;
    }

    int cubeDistance(ivec2 a, ivec2 b) {
      ivec2 c = b - a;
      int q = c[0];
      int r = c[1];
      return (abs(q) + abs(r) + abs(q + r)) / 2;
    }
  `;

  /* -------------------------------------------- */

  /**
   * Get the nearest vertex of a grid space to the given point.
   * @type {string}
   */
  static NEAREST_VERTEX_FUNCTION = `
    vec2 nearestVertex(vec2 p) {
      if ( TYPE_IS_SQUARE ) {
        return floor(p + 0.5);
      }

      if ( TYPE_IS_HEXAGONAL ) {
        vec2 c = cubeToPoint(cubeRound(pointToCube(p)));
        vec2 d = p - c;
        float a = atan(d.y, d.x);
        if ( TYPE_IS_HEXAGONAL_COLUMNS ) {
          a = floor((a / (PI / 3.0)) + 0.5) * (PI / 3.0);
        } else {
          a = (floor(a / (PI / 3.0)) + 0.5) * (PI / 3.0);
        }
        return c + (vec2(cos(a), sin(a)) * SQRT1_3);
      }
    }
  `;

  /* -------------------------------------------- */

  /**
   * This function returns the distance to the nearest edge of a grid space given a point.
   * @type {string}
   */
  static EDGE_DISTANCE_FUNCTION = `
    float edgeDistance(vec2 p) {
      if ( TYPE_IS_SQUARE ) {
        vec2 d = abs(p - floor(p + 0.5));
        return min(d.x, d.y);
      }

      if ( TYPE_IS_HEXAGONAL ) {
        vec2 a = pointToCube(p);
        vec2 b = cubeRound(a);
        vec2 c = b - a;
        float q = c[0];
        float r = c[1];
        float s = -q - r;
        return (2.0 - (abs(q - r) + abs(r - s) + abs(s - q))) * 0.25;
      }
    }
  `;

  /* -------------------------------------------- */

  /**
   * This function returns an vector (x, y, z), where
   * - x is the x-offset along the nearest edge,
   * - y is the y-offset (the distance) from the nearest edge, and
   * - z is the length of the nearest edge.
   * @type {string}
   */
  static EDGE_OFFSET_FUNCTION = `
    vec3 edgeOffset(vec2 p) {
      if ( TYPE_IS_SQUARE ) {
        vec2 d = abs(p - floor(p + 0.5));
        return vec3(max(d.x, d.y), min(d.x, d.y), 1.0);
      }

      if ( TYPE_IS_HEXAGONAL ) {
        vec2 c = cubeToPoint(cubeRound(pointToCube(p)));
        vec2 d = p - c;
        float a = atan(d.y, d.x);
        if ( TYPE_IS_HEXAGONAL_COLUMNS ) {
          a = (floor(a / (PI / 3.0)) + 0.5) * (PI / 3.0);
        } else {
          a = floor((a / (PI / 3.0)) + 0.5) * (PI / 3.0);
        }
        vec2 n = vec2(cos(a), sin(a));
        return vec3((0.5 * SQRT1_3) + dot(d, vec2(-n.y, n.x)), 0.5 - dot(d, n), SQRT1_3);
      }
    }
  `;

  /* -------------------------------------------- */

  /**
   * A function that draws the grid given a grid point, style, thickness, and color.
   * @type {string}
   */
  static DRAW_GRID_FUNCTION = `
    const int STYLE_LINE_SOLID = 0;
    const int STYLE_LINE_DASHED = 1;
    const int STYLE_LINE_DOTTED = 2;
    const int STYLE_POINT_SQUARE = 3;
    const int STYLE_POINT_DIAMOND = 4;
    const int STYLE_POINT_ROUND = 5;

    vec4 drawGrid(vec2 point, int style, float thickness, vec4 color) {
      float alpha;

      if ( style == STYLE_POINT_SQUARE ) {
        vec2 offset = abs(nearestVertex(point) - point);
        float distance = max(offset.x, offset.y);
        alpha = lineCoverage(distance, thickness);
      }

      else if ( style == STYLE_POINT_DIAMOND ) {
        vec2 offset = abs(nearestVertex(point) - point);
        float distance = (offset.x + offset.y) * SQRT1_2;
        alpha = lineCoverage(distance, thickness);
      }

      else if ( style == STYLE_POINT_ROUND ) {
        float distance = distance(point, nearestVertex(point));
        alpha = lineCoverage(distance, thickness);
      }

      else if ( style == STYLE_LINE_SOLID ) {
        float distance = edgeDistance(point);
        alpha = lineCoverage(distance, thickness);
      }

      else if ( (style == STYLE_LINE_DASHED) || (style == STYLE_LINE_DOTTED) ) {
        vec3 offset = edgeOffset(point);
        if ( (style == STYLE_LINE_DASHED) && TYPE_IS_HEXAGONAL ) {
          float padding = thickness * ((1.0 - SQRT1_3) * 0.5);
          offset.x += padding;
          offset.z += (padding * 2.0);
        }

        float intervals = offset.z * 0.5 / thickness;
        if ( intervals < 0.5 ) {
          alpha = lineCoverage(offset.y, thickness);
        } else {
          float interval = thickness * (2.0 * (intervals / floor(intervals + 0.5)));
          float dx = offset.x - (floor((offset.x / interval) + 0.5) * interval);
          float dy = offset.y;

          if ( style == STYLE_LINE_DOTTED ) {
            alpha = lineCoverage(length(vec2(dx, dy)), thickness);
          } else {
            alpha = min(lineCoverage(dx, thickness), lineCoverage(dy, thickness));
          }
        }
      }

      return color * alpha;
    }
  `;

  /* -------------------------------------------- */

  /** @override */
  static vertexShader = `
    #version 300 es

    ${this.GLSL1_COMPATIBILITY_VERTEX}

    precision ${PIXI.settings.PRECISION_VERTEX} float;

    in vec2 aVertexPosition;

    uniform mat3 translationMatrix;
    uniform mat3 projectionMatrix;
    uniform vec4 meshDimensions;
    uniform vec2 canvasDimensions;
    uniform vec4 sceneDimensions;
    uniform vec2 screenDimensions;
    uniform float gridSize;

    out vec2 vGridCoord; // normalized grid coordinates
    out vec2 vCanvasCoord; // normalized canvas coordinates
    out vec2 vSceneCoord; // normalized scene coordinates
    out vec2 vScreenCoord; // normalized screen coordinates

    void main() {
      vec2 pixelCoord = (aVertexPosition * meshDimensions.zw) + meshDimensions.xy;
      vGridCoord = pixelCoord / gridSize;
      vCanvasCoord = pixelCoord / canvasDimensions;
      vSceneCoord = (pixelCoord - sceneDimensions.xy) / sceneDimensions.zw;
      vec3 tPos = translationMatrix * vec3(aVertexPosition, 1.0);
      vScreenCoord = tPos.xy / screenDimensions;
      gl_Position = vec4((projectionMatrix * tPos).xy, 0.0, 1.0);
    }
  `;

  /* -------------------------------------------- */

  /** @override */
  static get fragmentShader() {
    return `
      #version 300 es

      ${this.GLSL1_COMPATIBILITY_FRAGMENT}

      precision ${PIXI.settings.PRECISION_FRAGMENT} float;

      in vec2 vGridCoord; // normalized grid coordinates
      in vec2 vCanvasCoord; // normalized canvas coordinates
      in vec2 vSceneCoord; // normalized scene coordinates
      in vec2 vScreenCoord; // normalized screen coordinates

      ${this.CONSTANTS}

      ${this.TYPE_UNIFORM}
      ${this.THICKNESS_UNIFORM}
      ${this.COLOR_UNIFORM}
      ${this.RESOLUTION_UNIFORM}

      ${this.ANTIALIASED_STEP_FUNCTION}
      ${this.LINE_COVERAGE_FUNCTION}
      ${this.HEXAGONAL_FUNCTIONS}
      ${this.NEAREST_VERTEX_FUNCTION}
      ${this.EDGE_DISTANCE_FUNCTION}
      ${this.EDGE_OFFSET_FUNCTION}

      ${this._fragmentShader}

      uniform float alpha;

      out vec4 fragColor;

      void main() {
        fragColor = _main() * alpha;
      }
    `;
  }

  /* ---------------------------------------- */

  /**
   * The fragment shader source. Subclasses can override it.
   * @type {string}
   * @protected
   */
  static _fragmentShader = `
    uniform lowp int style;

    ${this.DRAW_GRID_FUNCTION}

    vec4 _main() {
      return drawGrid(vGridCoord, style, thickness, color);
    }
  `;

  /* ---------------------------------------- */

  /** @override */
  static defaultUniforms = {
    canvasDimensions: [1, 1],
    meshDimensions: [0, 0, 1, 1],
    sceneDimensions: [0, 0, 1, 1],
    screenDimensions: [1, 1],
    gridSize: 1,
    type: 0,
    thickness: 0,
    resolution: 0,
    color: [0, 0, 0, 0],
    alpha: 0,
    style: 0
  };

  /* ---------------------------------------- */

  /**
   * Configure the shader.
   * @param {object} options
   */
  configure(options) {
    if ( "style" in options ) {
      this.uniforms.style = options.style ?? 0;
    }
  }

  /* ---------------------------------------- */

  #color = new PIXI.Color(0);

  /* ---------------------------------------- */

  /** @override */
  _preRender(mesh, renderer) {
    const data = mesh.data;
    const size = data.size;
    const uniforms = this.uniforms;
    const dimensions = canvas.dimensions;
    uniforms.meshDimensions[0] = mesh.x;
    uniforms.meshDimensions[1] = mesh.y;
    uniforms.meshDimensions[2] = data.width; // === mesh.width
    uniforms.meshDimensions[3] = data.height; // === mesh.height
    uniforms.canvasDimensions[0] = dimensions.width;
    uniforms.canvasDimensions[1] = dimensions.height;
    uniforms.sceneDimensions = dimensions.sceneRect;
    uniforms.screenDimensions = canvas.screenDimensions;
    uniforms.gridSize = size;
    uniforms.type = data.type;
    uniforms.thickness = data.thickness / size;
    uniforms.alpha = mesh.worldAlpha;
    this.#color.setValue(data.color).toArray(uniforms.color);

    // Only uniform scale is supported!
    const {resolution} = renderer.renderTexture.current ?? renderer;
    let scale = resolution * mesh.worldTransform.a / data.width;
    const projection = renderer.projection.transform;
    if ( projection ) {
      const {a, b} = projection;
      scale *= Math.sqrt((a * a) + (b * b));
    }
    uniforms.resolution = scale * size;
  }
}

/**
 * The base shader class for weather shaders.
 */
class AbstractWeatherShader extends AbstractBaseShader {
  constructor(...args) {
    super(...args);
    Object.defineProperties(this, Object.keys(this.constructor.defaultUniforms).reduce((obj, k) => {
      obj[k] = {
        get() {
          return this.uniforms[k];
        },
        set(value) {
          this.uniforms[k] = value;
        },
        enumerable: false
      };
      return obj;
    }, {}));
  }

  /**
   * Compute the weather masking value.
   * @type {string}
   */
  static COMPUTE_MASK = `
    // Base mask value 
    float mask = 1.0;
    
    // Process the occlusion mask
    if ( useOcclusion ) {
      float oMask = step(depthElevation, (254.5 / 255.0) - dot(occlusionWeights, texture2D(occlusionTexture, vUvsOcclusion)));
      if ( reverseOcclusion ) oMask = 1.0 - oMask;
      mask *= oMask;
    }
                  
    // Process the terrain mask 
    if ( useTerrain ) {
      float tMask = dot(terrainWeights, texture2D(terrainTexture, vUvsTerrain));
      if ( reverseTerrain ) tMask = 1.0 - tMask;
      mask *= tMask;
    }
  `;

  /**
   * Compute the weather masking value.
   * @type {string}
   */
  static FRAGMENT_HEADER = `
    precision ${PIXI.settings.PRECISION_FRAGMENT} float;
    
    // Occlusion mask uniforms
    uniform bool useOcclusion;
    uniform sampler2D occlusionTexture;
    uniform bool reverseOcclusion;
    uniform vec4 occlusionWeights;
    
    // Terrain mask uniforms
    uniform bool useTerrain;
    uniform sampler2D terrainTexture;
    uniform bool reverseTerrain;
    uniform vec4 terrainWeights;

    // Other uniforms and varyings
    uniform vec3 tint;
    uniform float time;
    uniform float depthElevation;
    uniform float alpha;
    varying vec2 vUvsOcclusion;
    varying vec2 vUvsTerrain;
    varying vec2 vStaticUvs;
    varying vec2 vUvs;
  `;

  /**
   * Common uniforms for all weather shaders.
   * @type {{
   *  useOcclusion: boolean,
   *  occlusionTexture: PIXI.Texture|null,
   *  reverseOcclusion: boolean,
   *  occlusionWeights: number[],
   *  useTerrain: boolean,
   *  terrainTexture: PIXI.Texture|null,
   *  reverseTerrain: boolean,
   *  terrainWeights: number[],
   *  alpha: number,
   *  tint: number[],
   *  screenDimensions: [number, number],
   *  effectDimensions: [number, number],
   *  depthElevation: number,
   *  time: number
   * }}
   */
  static commonUniforms = {
    terrainUvMatrix: new PIXI.Matrix(),
    useOcclusion: false,
    occlusionTexture: null,
    reverseOcclusion: false,
    occlusionWeights: [0, 0, 1, 0],
    useTerrain: false,
    terrainTexture: null,
    reverseTerrain: false,
    terrainWeights: [1, 0, 0, 0],
    alpha: 1,
    tint: [1, 1, 1],
    screenDimensions: [1, 1],
    effectDimensions: [1, 1],
    depthElevation: 1,
    time: 0
  };

  /**
   * Default uniforms for a specific class
   * @abstract
   */
  static defaultUniforms;

  /* -------------------------------------------- */

  /** @override */
  static create(initialUniforms) {
    const program = this.createProgram();
    const uniforms = {...this.commonUniforms, ...this.defaultUniforms, ...initialUniforms};
    return new this(program, uniforms);
  }

  /* -------------------------------------------- */

  /**
   * Create the shader program.
   * @returns {PIXI.Program}
   */
  static createProgram() {
    return PIXI.Program.from(this.vertexShader, this.fragmentShader);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static vertexShader = `
    precision ${PIXI.settings.PRECISION_VERTEX} float;
    attribute vec2 aVertexPosition;
    uniform mat3 translationMatrix;
    uniform mat3 projectionMatrix;
    uniform mat3 terrainUvMatrix;
    uniform vec2 screenDimensions;
    uniform vec2 effectDimensions;
    varying vec2 vUvsOcclusion;
    varying vec2 vUvsTerrain;
    varying vec2 vUvs;
    varying vec2 vStaticUvs;
  
    void main() {    
      vec3 tPos = translationMatrix * vec3(aVertexPosition, 1.0);
      vStaticUvs = aVertexPosition;
      vUvs = vStaticUvs * effectDimensions;
      vUvsOcclusion = tPos.xy / screenDimensions;
      vUvsTerrain = (terrainUvMatrix * vec3(aVertexPosition, 1.0)).xy;
      gl_Position = vec4((projectionMatrix * tPos).xy, 0.0, 1.0);
    }
  `;

  /* -------------------------------------------- */
  /*  Common Management and Parameters            */
  /* -------------------------------------------- */

  /**
   * Update the scale of this effect with new values
   * @param {number|{x: number, y: number}} scale    The desired scale
   */
  set scale(scale) {
    this.#scale.x = typeof scale === "object" ? scale.x : scale;
    this.#scale.y = (typeof scale === "object" ? scale.y : scale) ?? this.#scale.x;
  }

  set scaleX(x) {
    this.#scale.x = x ?? 1;
  }

  set scaleY(y) {
    this.#scale.y = y ?? 1;
  }

  #scale = {
    x: 1,
    y: 1
  };

  /* -------------------------------------------- */

  /**
   * The speed multiplier applied to animation.
   * 0 stops animation.
   * @type {number}
   */
  speed = 1;

  /* -------------------------------------------- */

  /** @override */
  _preRender(mesh, renderer) {
    this.uniforms.alpha = mesh.worldAlpha;
    this.uniforms.depthElevation = canvas.masks.depth.mapElevation(canvas.weather.elevation);
    this.uniforms.time += (canvas.app.ticker.deltaMS / 1000 * this.speed);
    this.uniforms.screenDimensions = canvas.screenDimensions;
    this.uniforms.effectDimensions[0] = this.#scale.x * mesh.scale.x / 10000;
    this.uniforms.effectDimensions[1] = this.#scale.y * mesh.scale.y / 10000;
  }
}

/**
 * An interface for defining shader-based weather effects
 * @param {object} config   The config object to create the shader effect
 */
class WeatherShaderEffect extends QuadMesh {
  constructor(config, shaderClass) {
    super(shaderClass);
    this.stop();
    this._initialize(config);
  }

  /* -------------------------------------------- */

  /**
   * Set shader parameters.
   * @param {object} [config={}]
   */
  configure(config={}) {
    for ( const [k, v] of Object.entries(config) ) {
      if ( k in this.shader ) this.shader[k] = v;
      else if ( k in this.shader.uniforms ) this.shader.uniforms[k] = v;
    }
  }

  /* -------------------------------------------- */

  /**
   * Begin animation
   */
  play() {
    this.visible = true;
  }

  /* -------------------------------------------- */

  /**
   * Stop animation
   */
  stop() {
    this.visible = false;
  }

  /* -------------------------------------------- */

  /**
   * Initialize the weather effect.
   * @param {object} config        Config object.
   * @protected
   */
  _initialize(config) {
    this.configure(config);
    const sr = canvas.dimensions.sceneRect;
    this.position.set(sr.x, sr.y);
    this.width = sr.width;
    this.height = sr.height;
  }
}


/**
 * Fog shader effect.
 */
class FogShader extends AbstractWeatherShader {

  /** @inheritdoc */
  static defaultUniforms = {
    intensity: 1,
    rotation: 0,
    slope: 0.25
  };

  /* ---------------------------------------- */

  /**
   * Configure the number of octaves into the shaders.
   * @param {number} mode
   * @returns {string}
   */
  static OCTAVES(mode) {
    return `${mode + 2}`;
  }

  /* -------------------------------------------- */

  /**
   * Configure the fog complexity according to mode (performance).
   * @param {number} mode
   * @returns {string}
   */
  static FOG(mode) {
    if ( mode === 0 ) {
      return `vec2 mv = vec2(fbm(uv * 4.5 + time * 0.115)) * (1.0 + r * 0.25);
        mist += fbm(uv * 4.5 + mv - time * 0.0275) * (1.0 + r * 0.25);`;
    }
    return `for ( int i=0; i<2; i++ ) {
        vec2 mv = vec2(fbm(uv * 4.5 + time * 0.115 + vec2(float(i) * 250.0))) * (0.50 + r * 0.25);
        mist += fbm(uv * 4.5 + mv - time * 0.0275) * (0.50 + r * 0.25);
    }`;
  }

  /* -------------------------------------------- */

  /** @override */
  static createProgram() {
    const mode = canvas?.performance.mode ?? 2;
    return PIXI.Program.from(this.vertexShader, this.fragmentShader(mode));
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static fragmentShader(mode) {
    return `
    ${this.FRAGMENT_HEADER}
    uniform float intensity;
    uniform float slope;
    uniform float rotation;
    
    ${this.CONSTANTS}
    ${this.PERCEIVED_BRIGHTNESS}
    ${this.PRNG}
    ${this.ROTATION}
     
    // ********************************************************* //

    float fnoise(in vec2 coords) {
      vec2 i = floor(coords);
      vec2 f = fract(coords);
    
      float a = random(i);
      float b = random(i + vec2(1.0, 0.0));
      float c = random(i + vec2(0.0, 1.0));
      float d = random(i + vec2(1.0, 1.0));
      vec2 cb = f * f * (3.0 - 2.0 * f);
    
      return mix(a, b, cb.x) + (c - a) * cb.y * (1.0 - cb.x) + (d - b) * cb.x * cb.y;
    }
     
    // ********************************************************* //

    float fbm(in vec2 uv) {
      float r = 0.0;
      float scale = 1.0;  
      uv += time * 0.03;
      uv *= 2.0;
        
      for (int i = 0; i < ${this.OCTAVES(mode)}; i++) {
        r += fnoise(uv + time * 0.03) * scale;
        uv *= 3.0;
        scale *= 0.3;
      }
      return r;
    }
    
    // ********************************************************* //
    
    vec3 mist(in vec2 uv, in float r) {
      float mist = 0.0;
      ${this.FOG(mode)}
      return vec3(0.9, 0.85, 1.0) * mist;
    }
    
    // ********************************************************* //
    
    void main() {
      ${this.COMPUTE_MASK}
      
      vec2 ruv;
      if ( rotation != 0.0 ) {
        ruv = vUvs - 0.5;
        ruv *= rot(rotation);
        ruv += 0.5;
      }
      else {
        ruv = vUvs;
      }
      
      vec3 col = mist(ruv * 2.0 - 1.0, 0.0) * 1.33;
      float pb = perceivedBrightness(col);
      pb = smoothstep(slope * 0.5, slope + 0.001, pb);
      
      gl_FragColor = vec4( mix(vec3(0.05, 0.05, 0.08), col * clamp(slope, 1.0, 2.0), pb), 1.0) 
                     * vec4(tint, 1.0) * intensity * mask * alpha;
    }
    `;
  }
}


/**
 * Rain shader effect.
 */
class RainShader extends AbstractWeatherShader {

  /** @inheritdoc */
  static defaultUniforms = {
    opacity: 1,
    intensity: 1,
    strength: 1,
    rotation: 0.5,
    resolution: [3200, 80] // The resolution to have nice rain ropes with the voronoi cells
  };

  /* -------------------------------------------- */

  /** @inheritdoc */
  static fragmentShader = `
    ${this.FRAGMENT_HEADER}
    ${this.CONSTANTS}
    ${this.PERCEIVED_BRIGHTNESS}
    ${this.ROTATION}
    ${this.PRNG2D}
    ${this.VORONOI}
    
    uniform float intensity;
    uniform float opacity;
    uniform float strength;
    uniform float rotation;
    uniform vec2 resolution;

    // Compute rain according to uv and dimensions for layering
    float computeRain(in vec2 uv, in float t) {
      vec2 tuv = uv;
      vec2 ruv = ((tuv + 0.5) * rot(rotation)) - 0.5;
      ruv.y -= t * 0.8;
      vec2 st = ruv * resolution;
      vec3 d2 = voronoi(vec3(st - t * 0.5, t * 0.8), 10.0);
      float df = perceivedBrightness(d2);
      return (1.0 - smoothstep(-df * strength, df * strength + 0.001, 1.0 - smoothstep(0.3, 1.0, d2.z))) * intensity;
    }

    void main() {
      ${this.COMPUTE_MASK}
      gl_FragColor = vec4(vec3(computeRain(vUvs, time)) * tint, 1.0) * alpha * mask * opacity;
    }
  `;
}

/**
 * Snow shader effect.
 */
class SnowShader extends AbstractWeatherShader {

  /** @inheritdoc */
  static defaultUniforms = {
    direction: 1.2
  };

  /* -------------------------------------------- */

  /** @inheritdoc */
  static fragmentShader = `
    ${this.FRAGMENT_HEADER}
    uniform float direction;

    // Contribute to snow PRNG
    const mat3 prng = mat3(13.323122, 23.5112, 21.71123, 21.1212, 
                           28.731200, 11.9312, 21.81120, 14.7212, 61.3934);
       
    // Compute snow density according to uv and layer                       
    float computeSnowDensity(in vec2 uv, in float layer) {
      vec3 snowbase = vec3(floor(uv), 31.189 + layer);
      vec3 m = floor(snowbase) / 10000.0 + fract(snowbase);
      vec3 mp = (31415.9 + m) / fract(prng * m);
      vec3 r = fract(mp);
      vec2 s = abs(fract(uv) - 0.5 + 0.9 * r.xy - 0.45) + 0.01 * abs( 2.0 * fract(10.0 * uv.yx) - 1.0); 
      float d = 0.6 * (s.x + s.y) + max(s.x, s.y) - 0.01;
      float edge = 0.005 + 0.05 * min(0.5 * abs(layer - 5.0 - sin(time * 0.1)), 1.0);
      return smoothstep(edge * 2.0, -edge * 2.0, d) * r.x / (0.5 + 0.01 * layer * 1.5);
    }                
 
    void main() {
      ${this.COMPUTE_MASK}
      
      // Snow accumulation
      float accumulation = 0.0;
      
      // Compute layers  
      for ( float i=5.0; i<25.0; i++ ) {
        // Compute uv layerization
        vec2 snowuv = vUvs.xy * (1.0 + i * 1.5);
        snowuv += vec2(snowuv.y * 1.2 * (fract(i * 6.258817) - direction), -time / (1.0 + i * 1.5 * 0.03));
                   
        // Perform accumulation layer after layer    
        accumulation += computeSnowDensity(snowuv, i);
      }
      // Output the accumulated snow pixel
      gl_FragColor = vec4(vec3(accumulation) * tint, 1.0) * mask * alpha;
    }
  `;
}



/**
 * @typedef {object} ContextMenuEntry
 * @property {string} name               The context menu label. Can be localized.
 * @property {string} icon               A string containing an HTML icon element for the menu item
 * @property {string} [classes]          Additional CSS classes to apply to this menu item.
 * @property {string} group              An identifier for a group this entry belongs to.
 * @property {function(jQuery)} callback The function to call when the menu item is clicked. Receives the HTML element
 *                                       of the entry that this context menu is for.
 * @property {ContextMenuCondition|boolean} [condition] A function to call or boolean value to determine if this entry
 *                                                      appears in the menu.
 */

/**
 * @callback ContextMenuCondition
 * @param {jQuery} html  The HTML element of the context menu entry.
 * @returns {boolean}    Whether the entry should be rendered in the context menu.
 */

/**
 * @callback ContextMenuCallback
 * @param {HTMLElement} target  The element that the context menu has been triggered for.
 */

/**
 * Display a right-click activated Context Menu which provides a dropdown menu of options
 * A ContextMenu is constructed by designating a parent HTML container and a target selector
 * An Array of menuItems defines the entries of the menu which is displayed
 */
class ContextMenu {
  /**
   * @param {HTMLElement|jQuery} element                The containing HTML element within which the menu is positioned
   * @param {string} selector                           A CSS selector which activates the context menu.
   * @param {ContextMenuEntry[]} menuItems              An Array of entries to display in the menu
   * @param {object} [options]                          Additional options to configure the context menu.
   * @param {string} [options.eventName="contextmenu"]  Optionally override the triggering event which can spawn the
   *                                                    menu
   * @param {ContextMenuCallback} [options.onOpen]      A function to call when the context menu is opened.
   * @param {ContextMenuCallback} [options.onClose]     A function to call when the context menu is closed.
   */
  constructor(element, selector, menuItems, {eventName="contextmenu", onOpen, onClose}={}) {

    /**
     * The target HTMLElement being selected
     * @type {HTMLElement|jQuery}
     */
    this.element = element;

    /**
     * The target CSS selector which activates the menu
     * @type {string}
     */
    this.selector = selector || element.attr("id");

    /**
     * An interaction event name which activates the menu
     * @type {string}
     */
    this.eventName = eventName;

    /**
     * The array of menu items being rendered
     * @type {ContextMenuEntry[]}
     */
    this.menuItems = menuItems;

    /**
     * A function to call when the context menu is opened.
     * @type {Function}
     */
    this.onOpen = onOpen;

    /**
     * A function to call when the context menu is closed.
     * @type {Function}
     */
    this.onClose = onClose;

    /**
     * Track which direction the menu is expanded in
     * @type {boolean}
     */
    this._expandUp = false;

    // Bind to the current element
    this.bind();
  }

  /**
   * The parent HTML element to which the context menu is attached
   * @type {HTMLElement}
   */
  #target;

  /* -------------------------------------------- */

  /**
   * A convenience accessor to the context menu HTML object
   * @returns {*|jQuery.fn.init|jQuery|HTMLElement}
   */
  get menu() {
    return $("#context-menu");
  }

  /* -------------------------------------------- */

  /**
   * Create a ContextMenu for this Application and dispatch hooks.
   * @param {Application|ApplicationV2} app             The Application this ContextMenu belongs to.
   * @param {JQuery|HTMLElement} html                   The Application's rendered HTML.
   * @param {string} selector                           The target CSS selector which activates the menu.
   * @param {ContextMenuEntry[]} menuItems              The array of menu items being rendered.
   * @param {object} [options]                          Additional options to configure context menu initialization.
   * @param {string} [options.hookName="EntryContext"]  The name of the hook to call.
   * @returns {ContextMenu}
   */
  static create(app, html, selector, menuItems, {hookName="EntryContext", ...options}={}) {
    // FIXME ApplicationV2 does not support these hooks yet
    app._callHooks?.(className => `get${className}${hookName}`, menuItems);
    return new ContextMenu(html, selector, menuItems, options);
  }

  /* -------------------------------------------- */

  /**
   * Attach a ContextMenu instance to an HTML selector
   */
  bind() {
    const element = this.element instanceof HTMLElement ? this.element : this.element[0];
    element.addEventListener(this.eventName, event => {
      const matching = event.target.closest(this.selector);
      if ( !matching ) return;
      event.preventDefault();
      const priorTarget = this.#target;
      this.#target = matching;
      const menu = this.menu;

      // Remove existing context UI
      const prior = document.querySelector(".context");
      prior?.classList.remove("context");
      if ( this.#target.contains(menu[0]) ) return this.close();

      // If the menu is already open, call its close handler on its original target.
      ui.context?.onClose?.(priorTarget);

      // Render a new context menu
      event.stopPropagation();
      ui.context = this;
      this.onOpen?.(this.#target);
      return this.render($(this.#target), { event });
    });
  }

  /* -------------------------------------------- */

  /**
   * Closes the menu and removes it from the DOM.
   * @param {object} [options]                Options to configure the closing behavior.
   * @param {boolean} [options.animate=true]  Animate the context menu closing.
   * @returns {Promise<void>}
   */
  async close({animate=true}={}) {
    if ( animate ) await this._animateClose(this.menu);
    this._close();
  }

  /* -------------------------------------------- */

  _close() {
    for ( const item of this.menuItems ) {
      delete item.element;
    }
    this.menu.remove();
    $(".context").removeClass("context");
    delete ui.context;
    this.onClose?.(this.#target);
  }

  /* -------------------------------------------- */

  async _animateOpen(menu) {
    menu.hide();
    return new Promise(resolve => menu.slideDown(200, resolve));
  }

  /* -------------------------------------------- */

  async _animateClose(menu) {
    return new Promise(resolve => menu.slideUp(200, resolve));
  }

  /* -------------------------------------------- */

  /**
   * Render the Context Menu by iterating over the menuItems it contains.
   * Check the visibility of each menu item, and only render ones which are allowed by the item's logical condition.
   * Attach a click handler to each item which is rendered.
   * @param {jQuery} target                 The target element to which the context menu is attached
   * @param {object} [options]
   * @param {PointerEvent} [options.event]  The event that triggered the context menu opening.
   * @returns {Promise<jQuery>|void}        A Promise that resolves when the open animation has completed.
   */
  render(target, options={}) {
    const existing = $("#context-menu");
    let html = existing.length ? existing : $('<nav id="context-menu"></nav>');
    let ol = $('<ol class="context-items"></ol>');
    html.html(ol);

    if ( !this.menuItems.length ) return;

    const groups = this.menuItems.reduce((acc, entry) => {
      const group = entry.group ?? "_none";
      acc[group] ??= [];
      acc[group].push(entry);
      return acc;
    }, {});

    for ( const [group, entries] of Object.entries(groups) ) {
      let parent = ol;
      if ( group !== "_none" ) {
        const groupItem = $(`<li class="context-group" data-group-id="${group}"><ol></ol></li>`);
        ol.append(groupItem);
        parent = groupItem.find("ol");
      }
      for ( const item of entries ) {
        // Determine menu item visibility (display unless false)
        let display = true;
        if ( item.condition !== undefined ) {
          display = ( item.condition instanceof Function ) ? item.condition(target) : item.condition;
        }
        if ( !display ) continue;

        // Construct and add the menu item
        const name = game.i18n.localize(item.name);
        const classes = ["context-item", item.classes].filterJoin(" ");
        const li = $(`<li class="${classes}">${item.icon}${name}</li>`);
        li.children("i").addClass("fa-fw");
        parent.append(li);

        // Record a reference to the item
        item.element = li[0];
      }
    }

    // Bail out if there are no children
    if ( ol.children().length === 0 ) return;

    // Append to target
    this._setPosition(html, target, options);

    // Apply interactivity
    if ( !existing.length ) this.activateListeners(html);

    // Deactivate global tooltip
    game.tooltip.deactivate();

    // Animate open the menu
    return this._animateOpen(html);
  }

  /* -------------------------------------------- */

  /**
   * Set the position of the context menu, taking into consideration whether the menu should expand upward or downward
   * @param {jQuery} html                   The context menu element.
   * @param {jQuery} target                 The element that the context menu was spawned on.
   * @param {object} [options]
   * @param {PointerEvent} [options.event]  The event that triggered the context menu opening.
   * @protected
   */
  _setPosition(html, target, { event }={}) {
    const container = target[0].parentElement;

    // Append to target and get the context bounds
    target.css("position", "relative");
    html.css("visibility", "hidden");
    target.append(html);
    const contextRect = html[0].getBoundingClientRect();
    const parentRect = target[0].getBoundingClientRect();
    const containerRect = container.getBoundingClientRect();

    // Determine whether to expand upwards
    const contextTop = parentRect.top - contextRect.height;
    const contextBottom = parentRect.bottom + contextRect.height;
    const canOverflowUp = (contextTop > containerRect.top) || (getComputedStyle(container).overflowY === "visible");

    // If it overflows the container bottom, but not the container top
    const containerUp = ( contextBottom > containerRect.bottom ) && ( contextTop >= containerRect.top );
    const windowUp = ( contextBottom > window.innerHeight ) && ( contextTop > 0 ) && canOverflowUp;
    this._expandUp = containerUp || windowUp;

    // Display the menu
    html.toggleClass("expand-up", this._expandUp);
    html.toggleClass("expand-down", !this._expandUp);
    html.css("visibility", "");
    target.addClass("context");
  }

  /* -------------------------------------------- */

  /**
   * Local listeners which apply to each ContextMenu instance which is created.
   * @param {jQuery} html
   */
  activateListeners(html) {
    html.on("click", "li.context-item", this.#onClickItem.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on context menu items.
   * @param {PointerEvent} event      The click event
   */
  #onClickItem(event) {
    event.preventDefault();
    event.stopPropagation();
    const li = event.currentTarget;
    const item = this.menuItems.find(i => i.element === li);
    item?.callback($(this.#target));
    this.close();
  }

  /* -------------------------------------------- */

  /**
   * Global listeners which apply once only to the document.
   */
  static eventListeners() {
    document.addEventListener("click", ev => {
      if ( ui.context ) ui.context.close();
    });
  }
}

/* -------------------------------------------- */

/**
 * @typedef {ApplicationOptions} DialogOptions
 * @property {boolean} [jQuery=true]  Whether to provide jQuery objects to callback functions (if true) or plain
 *                                    HTMLElement instances (if false). This is currently true by default but in the
 *                                    future will become false by default.
 */

/**
 * @typedef {Object} DialogButton
 * @property {string} icon                  A Font Awesome icon for the button
 * @property {string} label                 The label for the button
 * @property {boolean} disabled             Whether the button is disabled
 * @property {function(jQuery)} [callback]  A callback function that fires when the button is clicked
 */

/**
 * @typedef {object} DialogData
 * @property {string} title                 The window title displayed in the dialog header
 * @property {string} content               HTML content for the dialog form
 * @property {Record<string, DialogButton>} buttons The buttons which are displayed as action choices for the dialog
 * @property {string} [default]             The name of the default button which should be triggered on Enter keypress
 * @property {function(jQuery)} [render]    A callback function invoked when the dialog is rendered
 * @property {function(jQuery)} [close]     Common callback operations to perform when the dialog is closed
 */

/**
 * Create a dialog window displaying a title, a message, and a set of buttons which trigger callback functions.
 * @param {DialogData} data          An object of dialog data which configures how the modal window is rendered
 * @param {DialogOptions} [options]  Dialog rendering options, see {@link Application}.
 *
 * @example Constructing a custom dialog instance
 * ```js
 * let d = new Dialog({
 *  title: "Test Dialog",
 *  content: "<p>You must choose either Option 1, or Option 2</p>",
 *  buttons: {
 *   one: {
 *    icon: '<i class="fas fa-check"></i>',
 *    label: "Option One",
 *    callback: () => console.log("Chose One")
 *   },
 *   two: {
 *    icon: '<i class="fas fa-times"></i>',
 *    label: "Option Two",
 *    callback: () => console.log("Chose Two")
 *   }
 *  },
 *  default: "two",
 *  render: html => console.log("Register interactivity in the rendered dialog"),
 *  close: html => console.log("This always is logged no matter which option is chosen")
 * });
 * d.render(true);
 * ```
 */
class Dialog extends Application {
  constructor(data, options) {
    super(options);
    this.data = data;
  }

  /**
   * A bound instance of the _onKeyDown method which is used to listen to keypress events while the Dialog is active.
   * @type {function(KeyboardEvent)}
   */
  #onKeyDown;

  /* -------------------------------------------- */

  /**
   * @override
   * @returns {DialogOptions}
   */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "templates/hud/dialog.html",
      focus: true,
      classes: ["dialog"],
      width: 400,
      jQuery: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get title() {
    return this.data.title || "Dialog";
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    let buttons = Object.keys(this.data.buttons).reduce((obj, key) => {
      let b = this.data.buttons[key];
      b.cssClass = (this.data.default === key ? [key, "default", "bright"] : [key]).join(" ");
      if ( b.condition !== false ) obj[key] = b;
      return obj;
    }, {});
    return {
      content: this.data.content,
      buttons: buttons
    };
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    html.find(".dialog-button").click(this._onClickButton.bind(this));

    // Prevent the default form submission action if any forms are present in this dialog.
    html.find("form").each((i, el) => el.onsubmit = evt => evt.preventDefault());
    if ( !this.#onKeyDown ) {
      this.#onKeyDown = this._onKeyDown.bind(this);
      document.addEventListener("keydown", this.#onKeyDown);
    }
    if ( this.data.render instanceof Function ) this.data.render(this.options.jQuery ? html : html[0]);

    if ( this.options.focus ) {
      // Focus the default option
      html.find(".default").focus();
    }

    html.find("[autofocus]")[0]?.focus();
  }

  /* -------------------------------------------- */

  /**
   * Handle a left-mouse click on one of the dialog choice buttons
   * @param {MouseEvent} event    The left-mouse click event
   * @private
   */
  _onClickButton(event) {
    const id = event.currentTarget.dataset.button;
    const button = this.data.buttons[id];
    this.submit(button, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle a keydown event while the dialog is active
   * @param {KeyboardEvent} event   The keydown event
   * @private
   */
  _onKeyDown(event) {

    // Cycle Options
    if ( event.key === "Tab" ) {
      const dialog = this.element[0];

      // If we are already focused on the Dialog, let the default browser behavior take over
      if ( dialog.contains(document.activeElement) ) return;

      // If we aren't focused on the dialog, bring focus to one of its buttons
      event.preventDefault();
      event.stopPropagation();
      const dialogButtons = Array.from(document.querySelectorAll(".dialog-button"));
      const targetButton = event.shiftKey ? dialogButtons.pop() : dialogButtons.shift();
      targetButton.focus();
    }

    // Close dialog
    if ( event.key === "Escape" ) {
      event.preventDefault();
      event.stopPropagation();
      return this.close();
    }

    // Confirm choice
    if ( event.key === "Enter" ) {

      // Only handle Enter presses if an input element within the Dialog has focus
      const dialog = this.element[0];
      if ( !dialog.contains(document.activeElement) || (document.activeElement instanceof HTMLTextAreaElement) ) return;
      event.preventDefault();
      event.stopPropagation();

      // Prefer a focused button, or enact the default option for the dialog
      const button = document.activeElement.dataset.button || this.data.default;
      const choice = this.data.buttons[button];
      return this.submit(choice);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderOuter() {
    let html = await super._renderOuter();
    const app = html[0];
    app.setAttribute("role", "dialog");
    app.setAttribute("aria-modal", "true");
    return html;
  }

  /* -------------------------------------------- */

  /**
   * Submit the Dialog by selecting one of its buttons
   * @param {Object} button         The configuration of the chosen button
   * @param {PointerEvent} event    The originating click event
   * @private
   */
  submit(button, event) {
    const target = this.options.jQuery ? this.element : this.element[0];
    try {
      if ( button.callback ) button.callback.call(this, target, event);
      this.close();
    } catch(err) {
      ui.notifications.error(err.message);
      throw new Error(err);
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async close(options={}) {
    if ( this.data.close ) this.data.close(this.options.jQuery ? this.element : this.element[0]);
    if ( this.#onKeyDown ) {
      document.removeEventListener("keydown", this.#onKeyDown);
      this.#onKeyDown = undefined;
    }
    return super.close(options);
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * A helper factory method to create simple confirmation dialog windows which consist of simple yes/no prompts.
   * If you require more flexibility, a custom Dialog instance is preferred.
   *
   * @param {DialogData} config                   Confirmation dialog configuration
   * @param {Function} [config.yes]               Callback function upon yes
   * @param {Function} [config.no]                Callback function upon no
   * @param {boolean} [config.defaultYes=true]    Make "yes" the default choice?
   * @param {boolean} [config.rejectClose=false]  Reject the Promise if the Dialog is closed without making a choice.
   * @param {DialogOptions} [config.options={}]   Additional rendering options passed to the Dialog
   *
   * @returns {Promise<any>}                      A promise which resolves once the user makes a choice or closes the
   *                                              window.
   *
   * @example Prompt the user with a yes or no question
   * ```js
   * let d = Dialog.confirm({
   *  title: "A Yes or No Question",
   *  content: "<p>Choose wisely.</p>",
   *  yes: () => console.log("You chose ... wisely"),
   *  no: () => console.log("You chose ... poorly"),
   *  defaultYes: false
   * });
   * ```
   */
  static async confirm({title, content, yes, no, render, defaultYes=true, rejectClose=false, options={}}={}) {
    return this.wait({
      title, content, render,
      focus: true,
      default: defaultYes ? "yes" : "no",
      close: () => {
        if ( rejectClose ) return;
        return null;
      },
      buttons: {
        yes: {
          icon: '<i class="fas fa-check"></i>',
          label: game.i18n.localize("Yes"),
          callback: html => yes ? yes(html) : true
        },
        no: {
          icon: '<i class="fas fa-times"></i>',
          label: game.i18n.localize("No"),
          callback: html => no ? no(html) : false
        }
      }
    }, options);
  }

  /* -------------------------------------------- */

  /**
   * A helper factory method to display a basic "prompt" style Dialog with a single button
   * @param {DialogData} config                  Dialog configuration options
   * @param {Function} [config.callback]         A callback function to fire when the button is clicked
   * @param {boolean} [config.rejectClose=true]  Reject the promise if the dialog is closed without confirming the
   *                                             choice, otherwise resolve as null
   * @param {DialogOptions} [config.options]     Additional dialog options
   * @returns {Promise<any>}                     The returned value from the provided callback function, if any
   */
  static async prompt({title, content, label, callback, render, rejectClose=true, options={}}={}) {
    return this.wait({
      title, content, render,
      default: "ok",
      close: () => {
        if ( rejectClose ) return;
        return null;
      },
      buttons: {
        ok: { icon: '<i class="fas fa-check"></i>', label, callback }
      }
    }, options);
  }

  /* -------------------------------------------- */

  /**
   * Wrap the Dialog with an enclosing Promise which resolves or rejects when the client makes a choice.
   * @param {DialogData} [data]        Data passed to the Dialog constructor.
   * @param {DialogOptions} [options]  Options passed to the Dialog constructor.
   * @param {object} [renderOptions]   Options passed to the Dialog render call.
   * @returns {Promise<any>}           A Promise that resolves to the chosen result.
   */
  static async wait(data={}, options={}, renderOptions={}) {
    return new Promise((resolve, reject) => {

      // Wrap buttons with Promise resolution.
      const buttons = foundry.utils.deepClone(data.buttons);
      for ( const [id, button] of Object.entries(buttons) ) {
        const cb = button.callback;
        function callback(html, event) {
          const result = cb instanceof Function ? cb.call(this, html, event) : undefined;
          resolve(result === undefined ? id : result);
        }
        button.callback = callback;
      }

      // Wrap close with Promise resolution or rejection.
      const originalClose = data.close;
      const close = () => {
        const result = originalClose instanceof Function ? originalClose() : undefined;
        if ( result !== undefined ) resolve(result);
        else reject(new Error("The Dialog was closed without a choice being made."));
      };

      // Construct the dialog.
      const dialog = new this({ ...data, buttons, close }, options);
      dialog.render(true, renderOptions);
    });
  }
}

/**
 * A UI utility to make an element draggable.
 * @param {Application} app             The Application that is being made draggable.
 * @param {jQuery} element              A JQuery reference to the Application's outer-most element.
 * @param {HTMLElement|boolean} handle  The element that acts as a drag handle. Supply false to disable dragging.
 * @param {boolean|object} resizable    Is the application resizable? Supply an object to configure resizing behaviour
 *                                      or true to have it automatically configured.
 * @param {string} [resizable.selector]       A selector for the resize handle.
 * @param {boolean} [resizable.resizeX=true]  Enable resizing in the X direction.
 * @param {boolean} [resizable.resizeY=true]  Enable resizing in the Y direction.
 * @param {boolean} [resizable.rtl]           Modify the resizing direction to be right-to-left.
 */
class Draggable {
  constructor(app, element, handle, resizable) {

    // Setup element data
    this.app = app;
    this.element = element[0];
    this.handle = handle ?? this.element;
    this.resizable = resizable || false;

    /**
     * Duplicate the application's starting position to track differences
     * @type {Object}
     */
    this.position = null;

    /**
     * Remember event handlers associated with this Draggable class so they may be later unregistered
     * @type {Object}
     */
    this.handlers = {};

    /**
     * Throttle mousemove event handling to 60fps
     * @type {number}
     */
    this._moveTime = 0;

    // Activate interactivity
    this.activateListeners();
  }

  /* ----------------------------------------- */

  /**
   * Activate event handling for a Draggable application
   * Attach handlers for floating, dragging, and resizing
   */
  activateListeners() {
    this._activateDragListeners();
    this._activateResizeListeners();
  }

  /* ----------------------------------------- */

  /**
   * Attach handlers for dragging and floating.
   * @protected
   */
  _activateDragListeners() {
    if ( !this.handle ) return;

    // Float to top
    this.handlers["click"] = ["pointerdown", ev => this.app.bringToTop(), {capture: true, passive: true}];
    this.element.addEventListener(...this.handlers.click);

    // Drag handlers
    this.handlers["dragDown"] = ["pointerdown", e => this._onDragMouseDown(e), false];
    this.handlers["dragMove"] = ["pointermove", e => this._onDragMouseMove(e), false];
    this.handlers["dragUp"] = ["pointerup", e => this._onDragMouseUp(e), false];
    this.handle.addEventListener(...this.handlers.dragDown);
    this.handle.classList.add("draggable");
  }

  /* ----------------------------------------- */

  /**
   * Attach handlers for resizing.
   * @protected
   */
  _activateResizeListeners() {
    if ( !this.resizable ) return;
    let handle = this.element.querySelector(this.resizable.selector);
    if ( !handle ) {
      handle = $('<div class="window-resizable-handle"><i class="fas fa-arrows-alt-h"></i></div>')[0];
      this.element.appendChild(handle);
    }

    // Register handlers
    this.handlers["resizeDown"] = ["pointerdown", e => this._onResizeMouseDown(e), false];
    this.handlers["resizeMove"] = ["pointermove", e => this._onResizeMouseMove(e), false];
    this.handlers["resizeUp"] = ["pointerup", e => this._onResizeMouseUp(e), false];

    // Attach the click handler and CSS class
    handle.addEventListener(...this.handlers.resizeDown);
    if ( this.handle ) this.handle.classList.add("resizable");
  }

  /* ----------------------------------------- */

  /**
   * Handle the initial mouse click which activates dragging behavior for the application
   * @private
   */
  _onDragMouseDown(event) {
    event.preventDefault();

    // Record initial position
    this.position = foundry.utils.deepClone(this.app.position);
    this._initial = {x: event.clientX, y: event.clientY};

    // Add temporary handlers
    window.addEventListener(...this.handlers.dragMove);
    window.addEventListener(...this.handlers.dragUp);
  }

  /* ----------------------------------------- */

  /**
   * Move the window with the mouse, bounding the movement to ensure the window stays within bounds of the viewport
   * @private
   */
  _onDragMouseMove(event) {
    event.preventDefault();

    // Limit dragging to 60 updates per second
    const now = Date.now();
    if ( (now - this._moveTime) < (1000/60) ) return;
    this._moveTime = now;

    // Update application position
    this.app.setPosition({
      left: this.position.left + (event.clientX - this._initial.x),
      top: this.position.top + (event.clientY - this._initial.y)
    });
  }

  /* ----------------------------------------- */

  /**
   * Conclude the dragging behavior when the mouse is release, setting the final position and removing listeners
   * @private
   */
  _onDragMouseUp(event) {
    event.preventDefault();
    window.removeEventListener(...this.handlers.dragMove);
    window.removeEventListener(...this.handlers.dragUp);
  }

  /* ----------------------------------------- */

  /**
   * Handle the initial mouse click which activates dragging behavior for the application
   * @private
   */
  _onResizeMouseDown(event) {
    event.preventDefault();

    // Limit dragging to 60 updates per second
    const now = Date.now();
    if ( (now - this._moveTime) < (1000/60) ) return;
    this._moveTime = now;

    // Record initial position
    this.position = foundry.utils.deepClone(this.app.position);
    if ( this.position.height === "auto" ) this.position.height = this.element.clientHeight;
    if ( this.position.width === "auto" ) this.position.width = this.element.clientWidth;
    this._initial = {x: event.clientX, y: event.clientY};

    // Add temporary handlers
    window.addEventListener(...this.handlers.resizeMove);
    window.addEventListener(...this.handlers.resizeUp);
  }

  /* ----------------------------------------- */

  /**
   * Move the window with the mouse, bounding the movement to ensure the window stays within bounds of the viewport
   * @private
   */
  _onResizeMouseMove(event) {
    event.preventDefault();
    const scale = this.app.position.scale ?? 1;
    let deltaX = (event.clientX - this._initial.x) / scale;
    const deltaY = (event.clientY - this._initial.y) / scale;
    if ( this.resizable.rtl === true ) deltaX *= -1;
    const newPosition = {
      width: this.position.width + deltaX,
      height: this.position.height + deltaY
    };
    if ( this.resizable.resizeX === false ) delete newPosition.width;
    if ( this.resizable.resizeY === false ) delete newPosition.height;
    this.app.setPosition(newPosition);
  }

  /* ----------------------------------------- */

  /**
   * Conclude the dragging behavior when the mouse is release, setting the final position and removing listeners
   * @private
   */
  _onResizeMouseUp(event) {
    event.preventDefault();
    window.removeEventListener(...this.handlers.resizeMove);
    window.removeEventListener(...this.handlers.resizeUp);
    this.app._onResize(event);
  }
}

/**
 * @typedef {object} DragDropConfiguration
 * @property {string} dragSelector     The CSS selector used to target draggable elements.
 * @property {string} dropSelector     The CSS selector used to target viable drop targets.
 * @property {Record<string,Function>} permissions    An object of permission test functions for each action
 * @property {Record<string,Function>} callbacks      An object of callback functions for each action
 */

/**
 * A controller class for managing drag and drop workflows within an Application instance.
 * The controller manages the following actions: dragstart, dragover, drop
 * @see {@link Application}
 *
 * @param {DragDropConfiguration}
 * @example Activate drag-and-drop handling for a certain set of elements
 * ```js
 * const dragDrop = new DragDrop({
 *   dragSelector: ".item",
 *   dropSelector: ".items",
 *   permissions: { dragstart: this._canDragStart.bind(this), drop: this._canDragDrop.bind(this) },
 *   callbacks: { dragstart: this._onDragStart.bind(this), drop: this._onDragDrop.bind(this) }
 * });
 * dragDrop.bind(html);
 * ```
 */
class DragDrop {
  constructor({dragSelector, dropSelector, permissions={}, callbacks={}} = {}) {

    /**
     * The HTML selector which identifies draggable elements
     * @type {string}
     */
    this.dragSelector = dragSelector;

    /**
     * The HTML selector which identifies drop targets
     * @type {string}
     */
    this.dropSelector = dropSelector;

    /**
     * A set of permission checking functions for each action of the Drag and Drop workflow
     * @type {Object}
     */
    this.permissions = permissions;

    /**
     * A set of callback functions for each action of the Drag and Drop workflow
     * @type {Object}
     */
    this.callbacks = callbacks;
  }

  /* -------------------------------------------- */

  /**
   * Bind the DragDrop controller to an HTML application
   * @param {HTMLElement} html    The HTML element to which the handler is bound
   */
  bind(html) {

    // Identify and activate draggable targets
    if ( this.can("dragstart", this.dragSelector) ) {
      const draggables = html.querySelectorAll(this.dragSelector);
      for (let el of draggables) {
        el.setAttribute("draggable", true);
        el.ondragstart = this._handleDragStart.bind(this);
      }
    }

    // Identify and activate drop targets
    if ( this.can("drop", this.dropSelector) ) {
      const droppables = !this.dropSelector || html.matches(this.dropSelector) ? [html] :
        html.querySelectorAll(this.dropSelector);
      for ( let el of droppables ) {
        el.ondragover = this._handleDragOver.bind(this);
        el.ondrop = this._handleDrop.bind(this);
      }
    }
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Execute a callback function associated with a certain action in the workflow
   * @param {DragEvent} event   The drag event being handled
   * @param {string} action     The action being attempted
   */
  callback(event, action) {
    const fn = this.callbacks[action];
    if ( fn instanceof Function ) return fn(event);
  }

  /* -------------------------------------------- */

  /**
   * Test whether the current user has permission to perform a step of the workflow
   * @param {string} action     The action being attempted
   * @param {string} selector   The selector being targeted
   * @return {boolean}          Can the action be performed?
   */
  can(action, selector) {
    const fn = this.permissions[action];
    if ( fn instanceof Function ) return fn(selector);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Handle the start of a drag workflow
   * @param {DragEvent} event   The drag event being handled
   * @private
   */
  _handleDragStart(event) {
    this.callback(event, "dragstart");
    if ( event.dataTransfer.items.length ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /**
   * Handle a dragged element over a droppable target
   * @param {DragEvent} event   The drag event being handled
   * @private
   */
  _handleDragOver(event) {
    event.preventDefault();
    this.callback(event, "dragover");
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle a dragged element dropped on a droppable target
   * @param {DragEvent} event   The drag event being handled
   * @private
   */
  _handleDrop(event) {
    event.preventDefault();
    return this.callback(event, "drop");
  }

  /* -------------------------------------------- */

  static createDragImage(img, width, height) {
    let div = document.getElementById("drag-preview");

    // Create the drag preview div
    if ( !div ) {
      div = document.createElement("div");
      div.setAttribute("id", "drag-preview");
      const img = document.createElement("img");
      img.classList.add("noborder");
      div.appendChild(img);
      document.body.appendChild(div);
    }

    // Add the preview image
    const i = div.children[0];
    i.src = img.src;
    i.width = width;
    i.height = height;
    return div;
  }
}

/**
 * A collection of helper functions and utility methods related to the rich text editor
 */
class TextEditor {

  /**
   * A singleton text area used for HTML decoding.
   * @type {HTMLTextAreaElement}
   */
  static #decoder = document.createElement("textarea");

  /**
   * Create a Rich Text Editor. The current implementation uses TinyMCE
   * @param {object} options                   Configuration options provided to the Editor init
   * @param {string} [options.engine=tinymce]  Which rich text editor engine to use, "tinymce" or "prosemirror". TinyMCE
   *                                           is deprecated and will be removed in a later version.
   * @param {string} content                   Initial HTML or text content to populate the editor with
   * @returns {Promise<TinyMCE.Editor|ProseMirrorEditor>}  The editor instance.
   */
  static async create({engine="tinymce", ...options}={}, content="") {
    if ( engine === "prosemirror" ) {
      const {target, ...rest} = options;
      return ProseMirrorEditor.create(target, content, rest);
    }
    if ( engine === "tinymce" ) return this._createTinyMCE(options, content);
    throw new Error(`Provided engine '${engine}' is not a valid TextEditor engine.`);
  }

  /**
   * A list of elements that are retained when truncating HTML.
   * @type {Set<string>}
   * @private
   */
  static _PARAGRAPH_ELEMENTS = new Set([
    "header", "main", "section", "article", "div", "footer", // Structural Elements
    "h1", "h2", "h3", "h4", "h5", "h6", // Headers
    "p", "blockquote", "summary", "span", "a", "mark", // Text Types
    "strong", "em", "b", "i", "u" // Text Styles
  ]);

  /* -------------------------------------------- */

  /**
   * Create a TinyMCE editor instance.
   * @param {object} [options]           Configuration options passed to the editor.
   * @param {string} [content=""]        Initial HTML or text content to populate the editor with.
   * @returns {Promise<TinyMCE.Editor>}  The TinyMCE editor instance.
   * @protected
   */
  static async _createTinyMCE(options={}, content="") {
    const mceConfig = foundry.utils.mergeObject(CONFIG.TinyMCE, options, {inplace: false});
    mceConfig.target = options.target;

    mceConfig.file_picker_callback = function (pickerCallback, value, meta) {
      let filePicker = new FilePicker({
        type: "image",
        callback: path => {
          pickerCallback(path);
          // Reset our z-index for next open
          $(".tox-tinymce-aux").css({zIndex: ''});
        },
      });
      filePicker.render();
      // Set the TinyMCE dialog to be below the FilePicker
      $(".tox-tinymce-aux").css({zIndex: Math.min(++_maxZ, 9999)});
    };
    if ( mceConfig.content_css instanceof Array ) {
      mceConfig.content_css = mceConfig.content_css.map(c => foundry.utils.getRoute(c)).join(",");
    }
    mceConfig.init_instance_callback = editor => {
      const window = editor.getWin();
      editor.focus();
      if ( content ) editor.resetContent(content);
      editor.selection.setCursorLocation(editor.getBody(), editor.getBody().childElementCount);
      window.addEventListener("wheel", event => {
        if ( event.ctrlKey ) event.preventDefault();
      }, {passive: false});
      editor.off("drop dragover"); // Remove the default TinyMCE dragdrop handlers.
      editor.on("drop", event => this._onDropEditorData(event, editor));
    };
    const [editor] = await tinyMCE.init(mceConfig);
    editor.document = options.document;
    return editor;
  }

  /* -------------------------------------------- */
  /*  HTML Manipulation Helpers
  /* -------------------------------------------- */

  /**
   * Safely decode an HTML string, removing invalid tags and converting entities back to unicode characters.
   * @param {string} html     The original encoded HTML string
   * @returns {string}        The decoded unicode string
   */
  static decodeHTML(html) {
    const d = TextEditor.#decoder;
    d.innerHTML = html;
    const decoded = d.value;
    d.innerHTML = "";
    return decoded;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} EnrichmentOptions
   * @property {boolean} [secrets=false]      Include unrevealed secret tags in the final HTML? If false, unrevealed
   *                                          secret blocks will be removed.
   * @property {boolean} [documents=true]     Replace dynamic document links?
   * @property {boolean} [links=true]         Replace hyperlink content?
   * @property {boolean} [rolls=true]         Replace inline dice rolls?
   * @property {boolean} [embeds=true]        Replace embedded content?
   * @property {object|Function} [rollData]   The data object providing context for inline rolls, or a function that
   *                                          produces it.
   * @property {ClientDocument} [relativeTo]  A document to resolve relative UUIDs against.
   */

  /**
   * Enrich HTML content by replacing or augmenting components of it
   * @param {string} content                  The original HTML content (as a string)
   * @param {EnrichmentOptions} [options={}]  Additional options which configure how HTML is enriched
   * @returns {Promise<string>}               The enriched HTML content
   */
  static async enrichHTML(content, options={}) {
    let {secrets=false, documents=true, links=true, embeds=true, rolls=true, rollData} = options;
    if ( !content?.length ) return "";

    // Create the HTML element
    const html = document.createElement("div");
    html.innerHTML = String(content || "");

    // Remove unrevealed secret blocks
    if ( !secrets ) html.querySelectorAll("section.secret:not(.revealed)").forEach(secret => secret.remove());

    // Increment embedded content depth recursion counter.
    options._embedDepth = (options._embedDepth ?? -1) + 1;

    // Plan text content replacements
    const fns = [];
    if ( documents ) fns.push(this._enrichContentLinks.bind(this));
    if ( links ) fns.push(this._enrichHyperlinks.bind(this));
    if ( rolls ) fns.push(this._enrichInlineRolls.bind(this, rollData));
    if ( embeds ) fns.push(this._enrichEmbeds.bind(this));
    for ( const config of CONFIG.TextEditor.enrichers ) {
      fns.push(this._applyCustomEnrichers.bind(this, config));
    }

    // Perform enrichment
    let text = this._getTextNodes(html);
    await this._primeCompendiums(text);

    let updateTextArray = false;
    for ( const fn of fns ) {
      if ( updateTextArray ) text = this._getTextNodes(html);
      updateTextArray = await fn(text, options);
    }
    return html.innerHTML;
  }

  /* -------------------------------------------- */

  /**
   * Scan for compendium UUIDs and retrieve Documents in batches so that they are in cache when enrichment proceeds.
   * @param {Text[]} text  The text nodes to scan.
   * @protected
   */
  static async _primeCompendiums(text) {
    // Scan for any UUID that looks like a compendium UUID. This should catch content links as well as UUIDs appearing
    // in embeds.
    const rgx = /Compendium\.[\w-]+\.[^.]+\.[a-zA-Z\d.]+/g;
    const packs = new Map();
    for ( const t of text ) {
      for ( const [uuid] of t.textContent.matchAll(rgx) ) {
        const { collection, documentId } = foundry.utils.parseUuid(uuid);
        if ( !collection || collection.has(documentId) ) continue;
        if ( !packs.has(collection) ) packs.set(collection, []);
        packs.get(collection).push(documentId);
      }
    }
    for ( const [pack, ids] of packs.entries() ) {
      await pack.getDocuments({ _id__in: ids });
    }
  }

  /* -------------------------------------------- */

  /**
   * Convert text of the form @UUID[uuid]{name} to anchor elements.
   * @param {Text[]} text                    The existing text content
   * @param {EnrichmentOptions} [options]    Options provided to customize text enrichment
   * @param {Document} [options.relativeTo]  A document to resolve relative UUIDs against.
   * @returns {Promise<boolean>}             Whether any content links were replaced and the text nodes need to be
   *                                         updated.
   * @protected
   */
  static async _enrichContentLinks(text, {relativeTo}={}) {
    const documentTypes = CONST.DOCUMENT_LINK_TYPES.concat(["Compendium", "UUID"]);
    const rgx = new RegExp(`@(${documentTypes.join("|")})\\[([^#\\]]+)(?:#([^\\]]+))?](?:{([^}]+)})?`, "g");
    return this._replaceTextContent(text, rgx, match => this._createContentLink(match, {relativeTo}));
  }

  /* -------------------------------------------- */

  /**
   * Handle embedding Document content with @Embed[uuid]{label} text.
   * @param {Text[]} text                  The existing text content.
   * @param {EnrichmentOptions} [options]  Options provided to customize text enrichment.
   * @returns {Promise<boolean>}           Whether any embeds were replaced and the text nodes need to be updated.
   * @protected
   */
  static async _enrichEmbeds(text, options={}) {
    const rgx = /@Embed\[(?<config>[^\]]+)](?:{(?<label>[^}]+)})?/gi;
    return this._replaceTextContent(text, rgx, match => this._embedContent(match, options), { replaceParent: true });
  }

  /* -------------------------------------------- */

  /**
   * Convert URLs into anchor elements.
   * @param {Text[]} text                 The existing text content
   * @param {EnrichmentOptions} [options] Options provided to customize text enrichment
   * @returns {Promise<boolean>}          Whether any hyperlinks were replaced and the text nodes need to be updated
   * @protected
   */
  static async _enrichHyperlinks(text, options={}) {
    const rgx = /(https?:\/\/)(www\.)?([^\s<]+)/gi;
    return this._replaceTextContent(text, rgx, this._createHyperlink);
  }

  /* -------------------------------------------- */

  /**
   * Convert text of the form [[roll]] to anchor elements.
   * @param {object|Function} rollData    The data object providing context for inline rolls.
   * @param {Text[]} text                 The existing text content.
   * @returns {Promise<boolean>}          Whether any inline rolls were replaced and the text nodes need to be updated.
   * @protected
   */
  static async _enrichInlineRolls(rollData, text) {
    rollData = rollData instanceof Function ? rollData() : (rollData || {});
    const rgx = /\[\[(\/[a-zA-Z]+\s)?(.*?)(]{2,3})(?:{([^}]+)})?/gi;
    return this._replaceTextContent(text, rgx, match => this._createInlineRoll(match, rollData));
  }

  /* -------------------------------------------- */

  /**
   * Match any custom registered regex patterns and apply their replacements.
   * @param {TextEditorEnricherConfig} config  The custom enricher configuration.
   * @param {Text[]} text                      The existing text content.
   * @param {EnrichmentOptions} [options]      Options provided to customize text enrichment
   * @returns {Promise<boolean>}               Whether any replacements were made, requiring the text nodes to be
   *                                           updated.
   * @protected
   */
  static async _applyCustomEnrichers({ pattern, enricher, replaceParent }, text, options) {
    return this._replaceTextContent(text, pattern, match => enricher(match, options), { replaceParent });
  }

  /* -------------------------------------------- */

  /**
   * Preview an HTML fragment by constructing a substring of a given length from its inner text.
   * @param {string} content    The raw HTML to preview
   * @param {number} length     The desired length
   * @returns {string}          The previewed HTML
   */
  static previewHTML(content, length=250) {
    let div = document.createElement("div");
    div.innerHTML = content;
    div = this.truncateHTML(div);
    div.innerText = this.truncateText(div.innerText, {maxLength: length});
    return div.innerHTML;
  }

  /* --------------------------------------------- */

  /**
   * Sanitises an HTML fragment and removes any non-paragraph-style text.
   * @param {HTMLElement} html       The root HTML element.
   * @returns {HTMLElement}
   */
  static truncateHTML(html) {
    const truncate = root => {
      for ( const node of root.childNodes ) {
        if ( [Node.COMMENT_NODE, Node.TEXT_NODE].includes(node.nodeType) ) continue;
        if ( node.nodeType === Node.ELEMENT_NODE ) {
          if ( this._PARAGRAPH_ELEMENTS.has(node.tagName.toLowerCase()) ) truncate(node);
          else node.remove();
        }
      }
    };

    const clone = html.cloneNode(true);
    truncate(clone);
    return clone;
  }

  /* -------------------------------------------- */

  /**
   * Truncate a fragment of text to a maximum number of characters.
   * @param {string} text           The original text fragment that should be truncated to a maximum length
   * @param {object} [options]      Options which affect the behavior of text truncation
   * @param {number} [options.maxLength]    The maximum allowed length of the truncated string.
   * @param {boolean} [options.splitWords]  Whether to truncate by splitting on white space (if true) or breaking words.
   * @param {string|null} [options.suffix]  A suffix string to append to denote that the text was truncated.
   * @returns {string}              The truncated text string
   */
  static truncateText(text, {maxLength=50, splitWords=true, suffix="…"}={}) {
    if ( text.length <= maxLength ) return text;

    // Split the string (on words if desired)
    let short;
    if ( splitWords ) {
      short = text.slice(0, maxLength + 10);
      while ( short.length > maxLength ) {
        if ( /\s/.test(short) ) short = short.replace(/[\s]+([\S]+)?$/, "");
        else short = short.slice(0, maxLength);
      }
    } else {
      short = text.slice(0, maxLength);
    }

    // Add a suffix and return
    suffix = suffix ?? "";
    return short + suffix;
  }

  /* -------------------------------------------- */
  /*  Text Node Manipulation
  /* -------------------------------------------- */

  /**
   * Recursively identify the text nodes within a parent HTML node for potential content replacement.
   * @param {HTMLElement} parent    The parent HTML Element
   * @returns {Text[]}              An array of contained Text nodes
   * @private
   */
  static _getTextNodes(parent) {
    const text = [];
    const walk = document.createTreeWalker(parent, NodeFilter.SHOW_TEXT);
    while ( walk.nextNode() ) text.push(walk.currentNode);
    return text;
  }

  /* -------------------------------------------- */

  /**
   * @typedef TextReplacementOptions
   * @property {boolean} [replaceParent]  Hoist the replacement element out of its containing element if it would be
   *                                      the only child of that element.
   */

  /**
   * @callback TextContentReplacer
   * @param {RegExpMatchArray} match  The regular expression match.
   * @returns {Promise<HTMLElement>}  The HTML to replace the matched content with.
   */

  /**
   * Facilitate the replacement of text node content using a matching regex rule and a provided replacement function.
   * @param {Text[]} text                       The text nodes to match and replace.
   * @param {RegExp} rgx                        The provided regular expression for matching and replacement
   * @param {TextContentReplacer} func          The replacement function
   * @param {TextReplacementOptions} [options]  Options to configure text replacement behavior.
   * @returns {boolean}                         Whether a replacement was made.
   * @private
   */
  static async _replaceTextContent(text, rgx, func, options={}) {
    let replaced = false;
    for ( const t of text ) {
      const matches = t.textContent.matchAll(rgx);
      for ( const match of Array.from(matches).reverse() ) {
        let result;
        try {
          result = await func(match);
        } catch(err) {
          Hooks.onError("TextEditor.enrichHTML", err, { log: "error" });
        }
        if ( result ) {
          this._replaceTextNode(t, match, result, options);
          replaced = true;
        }
      }
    }
    return replaced;
  }

  /* -------------------------------------------- */

  /**
   * Replace a matched portion of a Text node with a replacement Node
   * @param {Text} text                         The Text node containing the match.
   * @param {RegExpMatchArray} match            The regular expression match.
   * @param {Node} replacement                  The replacement Node.
   * @param {TextReplacementOptions} [options]  Options to configure text replacement behavior.
   * @private
   */
  static _replaceTextNode(text, match, replacement, { replaceParent }={}) {
    let target = text;
    if ( match.index > 0 ) target = text.splitText(match.index);
    if ( match[0].length < target.length ) target.splitText(match[0].length);
    const parent = target.parentElement;
    if ( parent.parentElement && (parent.childNodes.length < 2) && replaceParent ) parent.replaceWith(replacement);
    else target.replaceWith(replacement);
  }

  /* -------------------------------------------- */
  /*  Text Replacement Functions
  /* -------------------------------------------- */

  /**
   * Create a dynamic document link from a regular expression match
   * @param {RegExpMatchArray} match         The regular expression match
   * @param {object} [options]               Additional options to configure enrichment behaviour
   * @param {Document} [options.relativeTo]  A document to resolve relative UUIDs against.
   * @returns {Promise<HTMLAnchorElement>}   An HTML element for the document link.
   * @protected
   */
  static async _createContentLink(match, {relativeTo}={}) {
    const [type, target, hash, name] = match.slice(1, 5);

    // Prepare replacement data
    const data = {
      classes: ["content-link"],
      attrs: { draggable: "true" },
      dataset: { link: "" },
      name
    };

    let doc;
    let broken = false;
    if ( type === "UUID" ) {
      Object.assign(data.dataset, {link: "", uuid: target});
      doc = await fromUuid(target, {relative: relativeTo});
    }
    else broken = TextEditor._createLegacyContentLink(type, target, name, data);

    if ( doc ) {
      if ( doc.documentName ) return doc.toAnchor({ name: data.name, dataset: { hash } });
      data.name = data.name || doc.name || target;
      const type = game.packs.get(doc.pack)?.documentName;
      Object.assign(data.dataset, {type, id: doc._id, pack: doc.pack});
      if ( hash ) data.dataset.hash = hash;
      data.icon = CONFIG[type].sidebarIcon;
    }

    // The UUID lookup failed so this is a broken link.
    else if ( type === "UUID" ) broken = true;

    // Broken links
    if ( broken ) {
      delete data.dataset.link;
      delete data.attrs.draggable;
      data.icon = "fas fa-unlink";
      data.classes.push("broken");
    }
    return this.createAnchor(data);
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} EnrichmentAnchorOptions
   * @param {Record<string, string>} [attrs]    Attributes to set on the anchor.
   * @param {Record<string, string>} [dataset]  Data- attributes to set on the anchor.
   * @param {string[]} [classes]                Classes to add to the anchor.
   * @param {string} [name]                     The anchor's content.
   * @param {string} [icon]                     A font-awesome icon class to use as the icon.
   */

  /**
   * Helper method to create an anchor element.
   * @param {Partial<EnrichmentAnchorOptions>} [options]  Options to configure the anchor's construction.
   * @returns {HTMLAnchorElement}
   */
  static createAnchor({ attrs={}, dataset={}, classes=[], name, icon }={}) {
    name ??= game.i18n.localize("Unknown");
    const a = document.createElement("a");
    a.classList.add(...classes);
    for ( const [k, v] of Object.entries(attrs) ) {
      if ( (v !== null) && (v !== undefined) ) a.setAttribute(k, v);
    }
    for ( const [k, v] of Object.entries(dataset) ) {
      if ( (v !== null) && (v !== undefined) ) a.dataset[k] = v;
    }
    a.innerHTML = `${icon ? `<i class="${icon}"></i>` : ""}${name}`;
    return a;
  }

  /* -------------------------------------------- */

  /**
   * Embed content from another Document.
   * @param {RegExpMatchArray} match         The regular expression match.
   * @param {EnrichmentOptions} [options]    Options provided to customize text enrichment.
   * @returns {Promise<HTMLElement|null>}    A representation of the Document as HTML content, or null if the Document
   *                                         could not be embedded.
   * @protected
   */
  static async _embedContent(match, options={}) {
    if ( options._embedDepth > CONST.TEXT_ENRICH_EMBED_MAX_DEPTH ) {
      console.warn(`Nested Document embedding is restricted to a maximum depth of ${CONST.TEXT_ENRICH_EMBED_MAX_DEPTH}.`
        + ` ${match.input} cannot be fully enriched.`);
      return null;
    }

    const { label } = match.groups;
    const config = this._parseEmbedConfig(match.groups.config, { relative: options.relativeTo });
    const doc = await fromUuid(config.uuid, { relative: options.relativeTo });
    if ( doc ) return doc.toEmbed({ label, ...config }, options);

    const broken = document.createElement("p");
    broken.classList.add("broken", "content-embed");
    broken.innerHTML = `
      <i class="fas fa-circle-exclamation"></i>
      ${game.i18n.format("EDITOR.EmbedFailed", { uuid: config.uuid })}
    `;
    return broken;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {Record<string, string|boolean|number>} DocumentHTMLEmbedConfig
   * @property {string[]} values         Any strings that did not have a key name associated with them.
   * @property {string} [classes]        Classes to attach to the outermost element.
   * @property {boolean} [inline=false]  By default Documents are embedded inside a figure element. If this option is
   *                                     passed, the embed content will instead be included as part of the rest of the
   *                                     content flow, but still wrapped in a section tag for styling purposes.
   * @property {boolean} [cite=true]     Whether to include a content link to the original Document as a citation. This
   *                                     options is ignored if the Document is inlined.
   * @property {boolean} [caption=true]  Whether to include a caption. The caption will depend on the Document being
   *                                     embedded, but if an explicit label is provided, that will always be used as the
   *                                     caption. This option is ignored if the Document is inlined.
   * @property {string} [captionPosition="bottom"]  Controls whether the caption is rendered above or below the embedded
   *                                                content.
   * @property {string} [label]          The label.
   */

  /**
   * Parse the embed configuration to be passed to ClientDocument#toEmbed.
   * The return value will be an object of any key=value pairs included with the configuration, as well as a separate
   * values property that contains all the options supplied that were not in key=value format.
   * If a uuid key is supplied it is used as the Document's UUID, otherwise the first supplied UUID is used.
   * @param {string} raw        The raw matched config string.
   * @param {object} [options]  Options forwarded to parseUuid.
   * @returns {DocumentHTMLEmbedConfig}
   * @protected
   *
   * @example Example configurations.
   * ```js
   * TextEditor._parseEmbedConfig('uuid=Actor.xyz caption="Example Caption" cite=false');
   * // Returns: { uuid: "Actor.xyz", caption: "Example Caption", cite: false, values: [] }
   *
   * TextEditor._parseEmbedConfig('Actor.xyz caption="Example Caption" inline');
   * // Returns: { uuid: "Actor.xyz", caption: "Example Caption", values: ["inline"] }
   * ```
   */
  static _parseEmbedConfig(raw, options={}) {
    const config = { values: [] };
    for ( const part of raw.match(/(?:[^\s"]+|"[^"]*")+/g) ) {
      if ( !part ) continue;
      const [key, value] = part.split("=");
      const valueLower = value?.toLowerCase();
      if ( value === undefined ) config.values.push(key.replace(/(^"|"$)/g, ""));
      else if ( (valueLower === "true") || (valueLower === "false") ) config[key] = valueLower === "true";
      else if ( Number.isNumeric(value) ) config[key] = Number(value);
      else config[key] = value.replace(/(^"|"$)/g, "");
    }

    // Handle default embed configuration options.
    if ( !("cite" in config) ) config.cite = true;
    if ( !("caption" in config) ) config.caption = true;
    if ( !("inline" in config) ) {
      const idx = config.values.indexOf("inline");
      if ( idx > -1 ) {
        config.inline = true;
        config.values.splice(idx, 1);
      }
    }
    if ( !config.uuid ) {
      for ( const [i, value] of config.values.entries() ) {
        try {
          const parsed = foundry.utils.parseUuid(value, options);
          if ( parsed?.documentId ) {
            config.uuid = value;
            config.values.splice(i, 1);
            break;
          }
        } catch {}
      }
    }
    return config;
  }

  /* -------------------------------------------- */

  /**
   * Create a dynamic document link from an old-form document link expression.
   * @param {string} type    The matched document type, or "Compendium".
   * @param {string} target  The requested match target (_id or name).
   * @param {string} name    A customized or overridden display name for the link.
   * @param {object} data    Data containing the properties of the resulting link element.
   * @returns {boolean}      Whether the resulting link is broken or not.
   * @private
   */
  static _createLegacyContentLink(type, target, name, data) {
    let broken = false;

    // Get a matched World document
    if ( CONST.WORLD_DOCUMENT_TYPES.includes(type) ) {

      // Get the linked Document
      const config = CONFIG[type];
      const collection = game.collections.get(type);
      const document = foundry.data.validators.isValidId(target) ? collection.get(target) : collection.getName(target);
      if ( !document ) broken = true;

      // Update link data
      data.name = data.name || (broken ? target : document.name);
      data.icon = config.sidebarIcon;
      Object.assign(data.dataset, {type, uuid: document?.uuid});
    }

    // Get a matched PlaylistSound
    else if ( type === "PlaylistSound" ) {
      const [, playlistId, , soundId] = target.split(".");
      const playlist = game.playlists.get(playlistId);
      const sound = playlist?.sounds.get(soundId);
      if ( !playlist || !sound ) broken = true;

      data.name = data.name || (broken ? target : sound.name);
      data.icon = CONFIG.Playlist.sidebarIcon;
      Object.assign(data.dataset, {type, uuid: sound?.uuid});
      if ( sound?.playing ) data.cls.push("playing");
      if ( !game.user.isGM ) data.cls.push("disabled");
    }

    // Get a matched Compendium document
    else if ( type === "Compendium" ) {

      // Get the linked Document
      const { collection: pack, id } = foundry.utils.parseUuid(`Compendium.${target}`);
      if ( pack ) {
        Object.assign(data.dataset, {pack: pack.collection, uuid: pack.getUuid(id)});
        data.icon = CONFIG[pack.documentName].sidebarIcon;

        // If the pack is indexed, retrieve the data
        if ( pack.index.size ) {
          const index = pack.index.find(i => (i._id === id) || (i.name === id));
          if ( index ) {
            if ( !data.name ) data.name = index.name;
            data.dataset.id = index._id;
            data.dataset.uuid = index.uuid;
          }
          else broken = true;
        }

        // Otherwise assume the link may be valid, since the pack has not been indexed yet
        if ( !data.name ) data.name = data.dataset.lookup = id;
      }
      else broken = true;
    }
    return broken;
  }

  /* -------------------------------------------- */

  /**
   * Replace a hyperlink-like string with an actual HTML &lt;a> tag
   * @param {RegExpMatchArray} match        The regular expression match
   * @returns {Promise<HTMLAnchorElement>}  An HTML element for the document link
   * @private
   */
  static async _createHyperlink(match) {
    const href = match[0];
    const a = document.createElement("a");
    a.classList.add("hyperlink");
    a.href = a.textContent = href;
    a.target = "_blank";
    a.rel = "nofollow noopener";
    return a;
  }

  /* -------------------------------------------- */

  /**
   * Replace an inline roll formula with a rollable &lt;a> element or an eagerly evaluated roll result
   * @param {RegExpMatchArray} match             The regular expression match array
   * @param {object} rollData                    Provided roll data for use in roll evaluation
   * @returns {Promise<HTMLAnchorElement|null>}  The replaced match. Returns null if the contained command is not a
   *                                             valid roll expression.
   * @protected
   */
  static async _createInlineRoll(match, rollData) {
    let [command, formula, closing, label] = match.slice(1, 5);
    const rollCls = Roll.defaultImplementation;

    // Handle the possibility of the roll formula ending with a closing bracket
    if ( closing.length === 3 ) formula += "]";

    // If the tag does not contain a command, it may only be an eagerly-evaluated inline roll
    if ( !command ) {
      if ( !rollCls.validate(formula) ) return null;
      try {
        const anchorOptions = {classes: ["inline-roll", "inline-result"], dataset: {tooltip: formula}, label};
        const roll = await rollCls.create(formula, rollData).evaluate({ allowInteractive: false });
        return roll.toAnchor(anchorOptions);
      }
      catch { return null; }
    }

    // Otherwise verify that the tag contains a valid roll command
    const chatCommand = `${command}${formula}`;
    let parsedCommand = null;
    try {
      parsedCommand = ChatLog.parse(chatCommand);
    }
    catch { return null; }
    const [cmd, matches] = parsedCommand;
    if ( !["roll", "gmroll", "blindroll", "selfroll", "publicroll"].includes(cmd) ) return null;

    // Extract components of the matched command
    const matchedCommand = ChatLog.MULTILINE_COMMANDS.has(cmd) ? matches.pop() : matches;
    const matchedFormula = rollCls.replaceFormulaData(matchedCommand[2].trim(), rollData || {});
    const matchedFlavor = matchedCommand[3]?.trim();

    // Construct the deferred roll element
    const a = document.createElement("a");
    a.classList.add("inline-roll", parsedCommand[0]);
    a.dataset.mode = parsedCommand[0];
    a.dataset.flavor = matchedFlavor ?? label ?? "";
    a.dataset.formula = matchedFormula;
    a.dataset.tooltip = formula;
    a.innerHTML = `<i class="fas fa-dice-d20"></i>${label || matchedFormula}`;
    return a;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Activate interaction listeners for the interior content of the editor frame.
   */
  static activateListeners() {
    const body = $("body");
    body.on("click", "a[data-link]", this._onClickContentLink);
    body.on("dragstart", "a[data-link]", this._onDragContentLink);
    body.on("click", "a.inline-roll", this._onClickInlineRoll);
    body.on("click", "[data-uuid][data-content-embed] [data-action]", this._onClickEmbeddedAction);
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on Document Links
   * @param {Event} event
   * @private
   */
  static async _onClickContentLink(event) {
    event.preventDefault();
    const doc = await fromUuid(event.currentTarget.dataset.uuid);
    return doc?._onClickDocumentLink(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle actions in embedded content.
   * @param {PointerEvent} event  The originating event.
   * @protected
   */
  static async _onClickEmbeddedAction(event) {
    const { action } = event.target.dataset;
    const { uuid } = event.target.closest("[data-uuid]").dataset;
    const doc = await fromUuid(uuid);
    if ( !doc ) return;
    switch ( action ) {
      case "rollTable": doc._rollFromEmbeddedHTML(event); break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle left-mouse clicks on an inline roll, dispatching the formula or displaying the tooltip
   * @param {MouseEvent} event    The initiating click event
   * @private
   */
  static async _onClickInlineRoll(event) {
    event.preventDefault();
    const a = event.currentTarget;

    // For inline results expand or collapse the roll details
    if ( a.classList.contains("inline-result") ) {
      if ( a.classList.contains("expanded") ) {
        return Roll.defaultImplementation.collapseInlineResult(a);
      } else {
        return Roll.defaultImplementation.expandInlineResult(a);
      }
    }

    // Get the current speaker
    const cls = ChatMessage.implementation;
    const speaker = cls.getSpeaker();
    let actor = cls.getSpeakerActor(speaker);
    let rollData = actor ? actor.getRollData() : {};

    // Obtain roll data from the contained sheet, if the inline roll is within an Actor or Item sheet
    const sheet = a.closest(".sheet");
    if ( sheet ) {
      const app = ui.windows[sheet.dataset.appid];
      if ( ["Actor", "Item"].includes(app?.object?.documentName) ) rollData = app.object.getRollData();
    }

    // Execute a deferred roll
    const roll = Roll.create(a.dataset.formula, rollData);
    return roll.toMessage({flavor: a.dataset.flavor, speaker}, {rollMode: a.dataset.mode});
  }

  /* -------------------------------------------- */

  /**
   * Begin a Drag+Drop workflow for a dynamic content link
   * @param {Event} event   The originating drag event
   * @private
   */
  static _onDragContentLink(event) {
    event.stopPropagation();
    const a = event.currentTarget;
    let dragData = null;

    // Case 1 - Compendium Link
    if ( a.dataset.pack ) {
      const pack = game.packs.get(a.dataset.pack);
      let id = a.dataset.id;
      if ( a.dataset.lookup && pack.index.size ) {
        const entry = pack.index.find(i => (i._id === a.dataset.lookup) || (i.name === a.dataset.lookup));
        if ( entry ) id = entry._id;
      }
      if ( !a.dataset.uuid && !id ) return false;
      const uuid = a.dataset.uuid || pack.getUuid(id);
      dragData = { type: a.dataset.type || pack.documentName, uuid };
    }

    // Case 2 - World Document Link
    else {
      const doc = fromUuidSync(a.dataset.uuid);
      dragData = doc.toDragData();
    }

    event.originalEvent.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping of transferred data onto the active rich text editor
   * @param {DragEvent} event     The originating drop event which triggered the data transfer
   * @param {TinyMCE} editor      The TinyMCE editor instance being dropped on
   * @private
   */
  static async _onDropEditorData(event, editor) {
    event.preventDefault();
    const eventData = this.getDragEventData(event);
    const link = await TextEditor.getContentLink(eventData, {relativeTo: editor.document});
    if ( link ) editor.insertContent(link);
  }

  /* -------------------------------------------- */

  /**
   * Extract JSON data from a drag/drop event.
   * @param {DragEvent} event       The drag event which contains JSON data.
   * @returns {object}              The extracted JSON data. The object will be empty if the DragEvent did not contain
   *                                JSON-parseable data.
   */
  static getDragEventData(event) {
    if ( !("dataTransfer" in event) ) {  // Clumsy because (event instanceof DragEvent) doesn't work
      console.warn("Incorrectly attempted to process drag event data for an event which was not a DragEvent.");
      return {};
    }
    try {
      return JSON.parse(event.dataTransfer.getData("text/plain"));
    } catch(err) {
      return {};
    }
  }

  /* -------------------------------------------- */

  /**
   * Given a Drop event, returns a Content link if possible such as @Actor[ABC123], else null
   * @param {object} eventData                     The parsed object of data provided by the transfer event
   * @param {object} [options]                     Additional options to configure link creation.
   * @param {ClientDocument} [options.relativeTo]  A document to generate the link relative to.
   * @param {string} [options.label]               A custom label to use instead of the document's name.
   * @returns {Promise<string|null>}
   */
  static async getContentLink(eventData, options={}) {
    const cls = getDocumentClass(eventData.type);
    if ( !cls ) return null;
    const document = await cls.fromDropData(eventData);
    if ( !document ) return null;
    return document._createDocumentLink(eventData, options);
  }

  /* -------------------------------------------- */

  /**
   * Upload an image to a document's asset path.
   * @param {string} uuid        The document's UUID.
   * @param {File} file          The image file to upload.
   * @returns {Promise<string>}  The path to the uploaded image.
   * @internal
   */
  static async _uploadImage(uuid, file) {
    if ( !game.user.hasPermission("FILES_UPLOAD") ) {
      ui.notifications.error("EDITOR.NoUploadPermission", {localize: true});
      return;
    }

    ui.notifications.info("EDITOR.UploadingFile", {localize: true});
    const response = await FilePicker.upload(null, null, file, {uuid});
    return response?.path;
  }
}

// Global Export
window.TextEditor = TextEditor;

/**
 * @typedef {ApplicationOptions} FilePickerOptions
 * @property {"image"|"audio"|"video"|"text"|"imagevideo"|"font"|"folder"|"any"} [type="any"] A type of file to target
 * @property {string} [current]            The current file path being modified, if any
 * @property {string} [activeSource=data]  A current file source in "data", "public", or "s3"
 * @property {Function} [callback]         A callback function to trigger once a file has been selected
 * @property {boolean} [allowUpload=true]  A flag which permits explicitly disallowing upload, true by default
 * @property {HTMLElement} [field]         An HTML form field that the result of this selection is applied to
 * @property {HTMLButtonElement} [button]  An HTML button element which triggers the display of this picker
 * @property {Record<string, FavoriteFolder>} [favorites] The picker display mode in FilePicker.DISPLAY_MODES
 * @property {string} [displayMode]        The picker display mode in FilePicker.DISPLAY_MODES
 * @property {boolean} [tileSize=false]    Display the tile size configuration.
 * @property {string[]} [redirectToRoot]   Redirect to the root directory rather than starting in the source directory
 *                                         of one of these files.
 */

/**
 * The FilePicker application renders contents of the server-side public directory.
 * This app allows for navigating and uploading files to the public path.
 *
 * @param {FilePickerOptions} [options={}]  Options that configure the behavior of the FilePicker
 */
class FilePicker extends Application {
  constructor(options={}) {
    super(options);

    /**
     * The full requested path given by the user
     * @type {string}
     */
    this.request = options.current;

    /**
     * The file sources which are available for browsing
     * @type {object}
     */
    this.sources = Object.entries({
      data: {
        target: "",
        label: game.i18n.localize("FILES.SourceUser"),
        icon: "fas fa-database"
      },
      public: {
        target: "",
        label: game.i18n.localize("FILES.SourceCore"),
        icon: "fas fa-server"
      },
      s3: {
        buckets: [],
        bucket: "",
        target: "",
        label: game.i18n.localize("FILES.SourceS3"),
        icon: "fas fa-cloud"
      }
    }).reduce((obj, s) => {
      if ( game.data.files.storages.includes(s[0]) ) obj[s[0]] = s[1];
      return obj;
    }, {});

    /**
     * Track the active source tab which is being browsed
     * @type {string}
     */
    this.activeSource = options.activeSource || "data";

    /**
     * A callback function to trigger once a file has been selected
     * @type {Function}
     */
    this.callback = options.callback;

    /**
     * The latest set of results browsed from the server
     * @type {object}
     */
    this.results = {};

    /**
     * The general file type which controls the set of extensions which will be accepted
     * @type {string}
     */
    this.type = options.type ?? "any";

    /**
     * The target HTML element this file picker is bound to
     * @type {HTMLElement}
     */
    this.field = options.field;

    /**
     * A button which controls the display of the picker UI
     * @type {HTMLElement}
     */
    this.button = options.button;

    /**
     * The display mode of the FilePicker UI
     * @type {string}
     */
    this.displayMode = options.displayMode || this.constructor.LAST_DISPLAY_MODE;

    /**
     * The current set of file extensions which are being filtered upon
     * @type {string[]}
     */
    this.extensions = FilePicker.#getExtensions(this.type);

    // Infer the source
    const [source, target] = this._inferCurrentDirectory(this.request);
    this.activeSource = source;
    this.sources[source].target = target;

    // Track whether we have loaded files
    this._loaded = false;
  }

  /**
   * The allowed values for the type of this FilePicker instance.
   * @type {string[]}
   */
  static FILE_TYPES = ["image", "audio", "video", "text", "imagevideo", "font", "folder", "any"];

  /**
   * Record the last-browsed directory path so that re-opening a different FilePicker instance uses the same target
   * @type {string}
   */
  static LAST_BROWSED_DIRECTORY = "";

  /**
   * Record the last-configured tile size which can automatically be applied to new FilePicker instances
   * @type {number|null}
   */
  static LAST_TILE_SIZE = null;

  /**
   * Record the last-configured display mode so that re-opening a different FilePicker instance uses the same mode.
   * @type {string}
   */
  static LAST_DISPLAY_MODE = "list";

  /**
   * Enumerate the allowed FilePicker display modes
   * @type {string[]}
   */
  static DISPLAY_MODES = ["list", "thumbs", "tiles", "images"];

  /**
   * Cache the names of S3 buckets which can be used
   * @type {Array|null}
   */
  static S3_BUCKETS = null;

  /**
   * @typedef FavoriteFolder
   * @property {string} source        The source of the folder (e.g. "data", "public")
   * @property {string} path          The full path to the folder
   * @property {string} label         The label for the path
   */

  /**
   * Get favorite folders for quick access
   * @type {Record<string, FavoriteFolder>}
   */
  static get favorites() {
    return game.settings.get("core", "favoritePaths");
  }

  /* -------------------------------------------- */

  /**
   * Add the given path for the source to the favorites
   * @param {string} source     The source of the folder (e.g. "data", "public")
   * @param {string} path       The path to a folder
   * @returns {Promise<void>}
   */
  static async setFavorite(source, path ) {
    const favorites = foundry.utils.deepClone(this.favorites);
    // Standardize all paths to end with a "/".
    // Has the side benefit of ensuring that the root path which is normally an empty string has content.
    path = path.endsWith("/") ? path : `${path}/`;
    const alreadyFavorite = Object.keys(favorites).includes(`${source}-${path}`);
    if ( alreadyFavorite ) return ui.notifications.info(game.i18n.format("FILES.AlreadyFavorited", {path}));
    let label;
    if ( path === "/" ) label = "root";
    else {
      const directories = path.split("/");
      label = directories[directories.length - 2]; // Get the final part of the path for the label
    }
    favorites[`${source}-${path}`] = {source, path, label};
    await game.settings.set("core", "favoritePaths", favorites);
  }

  /* -------------------------------------------- */

  /**
   * Remove the given path from the favorites
   * @param {string} source     The source of the folder (e.g. "data", "public")
   * @param {string} path       The path to a folder
   * @returns {Promise<void>}
   */
  static async removeFavorite(source, path) {
    const favorites = foundry.utils.deepClone(this.favorites);
    delete favorites[`${source}-${path}`];
    await game.settings.set("core", "favoritePaths", favorites);
  }

  /* -------------------------------------------- */

  /**
   * @override
   * @returns {FilePickerOptions}
   */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "templates/apps/filepicker.html",
      classes: ["filepicker"],
      width: 546,
      tabs: [{navSelector: ".tabs"}],
      dragDrop: [{dragSelector: ".file", dropSelector: ".filepicker-body"}],
      tileSize: false,
      filters: [{inputSelector: 'input[name="filter"]', contentSelector: ".filepicker-body"}]
    });
  }

  /* -------------------------------------------- */

  /**
   * Given a current file path, determine the directory it belongs to
   * @param {string} target   The currently requested target path
   * @returns {string[]}      An array of the inferred source and target directory path
   */
  _inferCurrentDirectory(target) {

    // Determine target
    const ignored = [CONST.DEFAULT_TOKEN].concat(this.options.redirectToRoot ?? []);
    if ( !target || ignored.includes(target) ) target = this.constructor.LAST_BROWSED_DIRECTORY;
    let source = "data";

    // Check for s3 matches
    const s3Match = this.constructor.matchS3URL(target);
    if ( s3Match ) {
      this.sources.s3.bucket = s3Match.groups.bucket;
      source = "s3";
      target = s3Match.groups.key;
    }

    // Non-s3 URL matches
    else if ( ["http://", "https://"].some(c => target.startsWith(c)) ) target = "";

    // Local file matches
    else {
      const p0 = target.split("/").shift();
      const publicDirs = ["cards", "css", "fonts", "icons", "lang", "scripts", "sounds", "ui"];
      if ( publicDirs.includes(p0) ) source = "public";
    }

    // If the preferred source is not available, use the next available source.
    if ( !this.sources[source] ) {
      source = game.data.files.storages[0];
      // If that happens to be S3, pick the first available bucket.
      if ( source === "s3" ) {
        this.sources.s3.bucket = game.data.files.s3.buckets?.[0] ?? null;
        target = "";
      }
    }

    // Split off the file name and retrieve just the directory path
    let parts = target.split("/");
    if ( parts[parts.length - 1].indexOf(".") !== -1 ) parts.pop();
    const dir = parts.join("/");
    return [source, dir];
  }

  /* -------------------------------------------- */

  /**
   * Get the valid file extensions for a given named file picker type
   * @param {string} type
   * @returns {string[]}
   */
  static #getExtensions(type) {

    // Identify allowed extensions
    let types = [
      CONST.IMAGE_FILE_EXTENSIONS,
      CONST.AUDIO_FILE_EXTENSIONS,
      CONST.VIDEO_FILE_EXTENSIONS,
      CONST.TEXT_FILE_EXTENSIONS,
      CONST.FONT_FILE_EXTENSIONS,
      CONST.GRAPHICS_FILE_EXTENSIONS
    ].flatMap(extensions => Object.keys(extensions));
    if ( type === "folder" ) types = [];
    else if ( type === "font" ) types = Object.keys(CONST.FONT_FILE_EXTENSIONS);
    else if ( type === "text" ) types = Object.keys(CONST.TEXT_FILE_EXTENSIONS);
    else if ( type === "graphics" ) types = Object.keys(CONST.GRAPHICS_FILE_EXTENSIONS);
    else if ( type === "image" ) types = Object.keys(CONST.IMAGE_FILE_EXTENSIONS);
    else if ( type === "audio" ) types = Object.keys(CONST.AUDIO_FILE_EXTENSIONS);
    else if ( type === "video" ) types = Object.keys(CONST.VIDEO_FILE_EXTENSIONS);
    else if ( type === "imagevideo") {
      types = Object.keys(CONST.IMAGE_FILE_EXTENSIONS).concat(Object.keys(CONST.VIDEO_FILE_EXTENSIONS));
    }
    return types.map(t => `.${t.toLowerCase()}`);
  }

  /* -------------------------------------------- */

  /**
   * Test a URL to see if it matches a well known s3 key pattern
   * @param {string} url          An input URL to test
   * @returns {RegExpMatchArray|null}  A regular expression match
   */
  static matchS3URL(url) {
    const endpoint = game.data.files.s3?.endpoint;
    if ( !endpoint ) return null;

    // Match new style S3 urls
    const s3New = new RegExp(`^${endpoint.protocol}//(?<bucket>.*).${endpoint.host}/(?<key>.*)`);
    const matchNew = url.match(s3New);
    if ( matchNew ) return matchNew;

    // Match old style S3 urls
    const s3Old = new RegExp(`^${endpoint.protocol}//${endpoint.host}/(?<bucket>[^/]+)/(?<key>.*)`);
    return url.match(s3Old);
  }

  /* -------------------------------------------- */
  /*  FilePicker Properties                       */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    let type = this.type || "file";
    return game.i18n.localize(type === "imagevideo" ? "FILES.TitleImageVideo" : `FILES.Title${type.capitalize()}`);
  }

  /* -------------------------------------------- */

  /**
   * Return the source object for the currently active source
   * @type {object}
   */
  get source() {
    return this.sources[this.activeSource];
  }

  /* -------------------------------------------- */

  /**
   * Return the target directory for the currently active source
   * @type {string}
   */
  get target() {
    return this.source.target;
  }

  /* -------------------------------------------- */

  /**
   * Return a flag for whether the current user is able to upload file content
   * @type {boolean}
   */
  get canUpload() {
    if ( this.type === "folder" ) return false;
    if ( this.options.allowUpload === false ) return false;
    if ( !["data", "s3"].includes(this.activeSource) ) return false;
    return !game.user || game.user.can("FILES_UPLOAD");
  }

  /* -------------------------------------------- */

  /**
   * Return the upload URL to which the FilePicker should post uploaded files
   * @type {string}
   */
  static get uploadURL() {
    return foundry.utils.getRoute("upload");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    const result = this.result;
    const source = this.source;
    let target = decodeURIComponent(source.target);
    const isS3 = this.activeSource === "s3";

    // Sort directories alphabetically and store their paths
    let dirs = result.dirs.map(d => ({
      name: decodeURIComponent(d.split("/").pop()),
      path: d,
      private: result.private || result.privateDirs.includes(d)
    }));
    dirs = dirs.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));

    // Sort files alphabetically and store their client URLs
    let files = result.files.map(f => {
      let img = f;
      if ( VideoHelper.hasVideoExtension(f) ) img = "icons/svg/video.svg";
      else if ( foundry.audio.AudioHelper.hasAudioExtension(f) ) img = "icons/svg/sound.svg";
      else if ( !ImageHelper.hasImageExtension(f) ) img = "icons/svg/book.svg";
      return {
        name: decodeURIComponent(f.split("/").pop()),
        url: f,
        img: img
      };
    });
    files = files.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));

    // Return rendering data
    return {
      bucket: isS3 ? source.bucket : null,
      buckets: isS3 ? source.buckets.map(b => ({ value: b, label: b })) : null,
      canGoBack: this.activeSource !== "",
      canUpload: this.canUpload,
      canSelect: !this.options.tileSize,
      cssClass: [this.displayMode, result.private ? "private": "public"].join(" "),
      dirs: dirs,
      displayMode: this.displayMode,
      extensions: this.extensions,
      files: files,
      isS3: isS3,
      noResults: dirs.length + files.length === 0,
      selected: this.type === "folder" ? target : this.request,
      source: source,
      sources: this.sources,
      target: target,
      tileSize: this.options.tileSize ? (this.constructor.LAST_TILE_SIZE || canvas.dimensions.size) : null,
      user: game.user,
      submitText: this.type === "folder" ? "FILES.SelectFolder" : "FILES.SelectFile",
      favorites: this.constructor.favorites
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  setPosition(pos={}) {
    const currentPosition = super.setPosition(pos);
    const element = this.element[0];
    const content = element.querySelector(".window-content");
    const lists = element.querySelectorAll(".filepicker-body > ol");
    const scroll = content.scrollHeight - content.offsetHeight;
    if ( (scroll > 0) && lists.length ) {
      let maxHeight = Number(getComputedStyle(lists[0]).maxHeight.slice(0, -2));
      maxHeight -= Math.ceil(scroll / lists.length);
      lists.forEach(list => list.style.maxHeight = `${maxHeight}px`);
    }
    return currentPosition;
  }

  /* -------------------------------------------- */

  /**
   * Browse to a specific location for this FilePicker instance
   * @param {string} [target]   The target within the currently active source location.
   * @param {object} [options]  Browsing options
   */
  async browse(target, options={}) {

    // If the user does not have permission to browse, do not proceed
    if ( game.world && !game.user.can("FILES_BROWSE") ) return;

    // Configure browsing parameters
    target = typeof target === "string" ? target : this.target;
    const source = this.activeSource;
    options = foundry.utils.mergeObject({
      type: this.type,
      extensions: this.extensions,
      wildcard: false
    }, options);

    // Determine the S3 buckets which may be used
    if ( source === "s3" ) {
      if ( this.constructor.S3_BUCKETS === null ) {
        const buckets = await this.constructor.browse("s3", "");
        this.constructor.S3_BUCKETS = buckets.dirs;
      }
      this.sources.s3.buckets = this.constructor.S3_BUCKETS;
      if ( !this.source.bucket ) this.source.bucket = this.constructor.S3_BUCKETS[0];
      options.bucket = this.source.bucket;
    }

    // Avoid browsing certain paths
    if ( target.startsWith("/") ) target = target.slice(1);
    if ( target === CONST.DEFAULT_TOKEN ) target = this.constructor.LAST_BROWSED_DIRECTORY;

    // Request files from the server
    const result = await this.constructor.browse(source, target, options).catch(error => {
      ui.notifications.warn(error);
      return this.constructor.browse(source, "", options);
    });

    // Populate browser content
    this.result = result;
    this.source.target = result.target;
    if ( source === "s3" ) this.source.bucket = result.bucket;
    this.constructor.LAST_BROWSED_DIRECTORY = result.target;
    this._loaded = true;

    // Render the application
    this.render(true);
    return result;
  }

  /* -------------------------------------------- */

  /**
   * Browse files for a certain directory location
   * @param {string} source     The source location in which to browse. See FilePicker#sources for details
   * @param {string} target     The target within the source location
   * @param {object} options                Optional arguments
   * @param {string} [options.bucket]       A bucket within which to search if using the S3 source
   * @param {string[]} [options.extensions] An Array of file extensions to filter on
   * @param {boolean} [options.wildcard]    The requested dir represents a wildcard path
   *
   * @returns {Promise}          A Promise which resolves to the directories and files contained in the location
   */
  static async browse(source, target, options={}) {
    const data = {action: "browseFiles", storage: source, target: target};
    return FilePicker.#manageFiles(data, options);
  }

  /* -------------------------------------------- */

  /**
   * Configure metadata settings regarding a certain file system path
   * @param {string} source     The source location in which to browse. See FilePicker#sources for details
   * @param {string} target     The target within the source location
   * @param {object} options    Optional arguments which modify the request
   * @returns {Promise<object>}
   */
  static async configurePath(source, target, options={}) {
    const data = {action: "configurePath", storage: source, target: target};
    return FilePicker.#manageFiles(data, options);
  }

  /* -------------------------------------------- */

  /**
   * Create a subdirectory within a given source. The requested subdirectory path must not already exist.
   * @param {string} source     The source location in which to browse. See FilePicker#sources for details
   * @param {string} target     The target within the source location
   * @param {object} options    Optional arguments which modify the request
   * @returns {Promise<object>}
   */
  static async createDirectory(source, target, options={}) {
    const data = {action: "createDirectory", storage: source, target: target};
    return FilePicker.#manageFiles(data, options);
  }

  /* -------------------------------------------- */

  /**
   * General dispatcher method to submit file management commands to the server
   * @param {object} data         Request data dispatched to the server
   * @param {object} options      Options dispatched to the server
   * @returns {Promise<object>}   The server response
   */
  static async #manageFiles(data, options) {
    return new Promise((resolve, reject) => {
      game.socket.emit("manageFiles", data, options, result => {
        if ( result.error ) return reject(new Error(result.error));
        resolve(result);
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * Dispatch a POST request to the server containing a directory path and a file to upload
   * @param {string} source   The data source to which the file should be uploaded
   * @param {string} path     The destination path
   * @param {File} file       The File object to upload
   * @param {object} [body={}]  Additional file upload options sent in the POST body
   * @param {object} [options]  Additional options to configure how the method behaves
   * @param {boolean} [options.notify=true] Display a UI notification when the upload is processed
   * @returns {Promise<object>}  The response object
   */
  static async upload(source, path, file, body={}, {notify=true}={}) {

    // Create the form data to post
    const fd = new FormData();
    fd.set("source", source);
    fd.set("target", path);
    fd.set("upload", file);
    Object.entries(body).forEach(o => fd.set(...o));

    const notifications = Object.fromEntries(["ErrorSomethingWrong", "WarnUploadModules", "ErrorTooLarge"].map(key => {
      const i18n = `FILES.${key}`;
      return [key, game.i18n.localize(i18n)];
    }));

    // Dispatch the request
    try {
      const request = await fetch(this.uploadURL, {method: "POST", body: fd});
      const response = await request.json();

      // Attempt to obtain the response
      if ( response.error ) {
        ui.notifications.error(response.error);
        return false;
      } else if ( !response.path ) {
        if ( notify ) ui.notifications.error(notifications.ErrorSomethingWrong);
        else console.error(notifications.ErrorSomethingWrong);
        return;
      }

      // Check for uploads to system or module directories.
      const [packageType, packageId, folder] = response.path.split("/");
      if ( ["modules", "systems"].includes(packageType) ) {
        let pkg;
        if ( packageType === "modules" ) pkg = game.modules.get(packageId);
        else if ( packageId === game.system.id ) pkg = game.system;
        if ( !pkg?.persistentStorage || (folder !== "storage") ) {
          if ( notify ) ui.notifications.warn(notifications.WarnUploadModules);
          else console.warn(notifications.WarnUploadModules);
        }
      }

      // Display additional response messages
      if ( response.message ) {
        if ( notify ) ui.notifications.info(response.message);
        else console.info(response.message);
      }
      return response;
    }
    catch(e) {
      if ( (e instanceof foundry.utils.HttpError) && (e.code === 413) ) {
        if ( notify ) ui.notifications.error(notifications.ErrorTooLarge);
        else console.error(notifications.ErrorTooLarge);
        return;
      }
      return {};
    }
  }

  /* -------------------------------------------- */

  /**
   * A convenience function that uploads a file to a given package's persistent /storage/ directory
   * @param {string} packageId                The id of the package to which the file should be uploaded.
   *                                          Only supports Systems and Modules.
   * @param {string} path                     The relative destination path in the package's storage directory
   * @param {File} file                       The File object to upload
   * @param {object} [body={}]                Additional file upload options sent in the POST body
   * @param {object} [options]                Additional options to configure how the method behaves
   * @param {boolean} [options.notify=true]   Display a UI notification when the upload is processed
   * @returns {Promise<object>}               The response object
   */
  static async uploadPersistent(packageId, path, file, body={}, {notify=true}={}) {
    let pack = game.system.id === packageId ? game.system : game.modules.get(packageId);
    if ( !pack ) throw new Error(`Package ${packageId} not found`);
    if ( !pack.persistentStorage ) throw new Error(`Package ${packageId} does not have persistent storage enabled. `
      + "Set the \"persistentStorage\" flag to true in the package manifest.");
    const source = "data";
    const target = `${pack.type}s/${pack.id}/storage/${path}`;
    return this.upload(source, target, file, body, {notify});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  render(force, options) {
    if ( game.world && !game.user.can("FILES_BROWSE") ) return this;
    this.position.height = null;
    this.element.css({height: ""});
    this._tabs[0].active = this.activeSource;
    if ( !this._loaded ) {
      this.browse();
      return this;
    }
    else return super.render(force, options);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    const header = html.find("header.filepicker-header");
    const form = html[0];

    // Change the directory
    const target = header.find('input[name="target"]');
    target.on("keydown", this.#onRequestTarget.bind(this));
    target[0].focus();

    // Header Control Buttons
    html.find(".current-dir button").click(this.#onClickDirectoryControl.bind(this));

    // Change the S3 bucket
    html.find('select[name="bucket"]').change(this.#onChangeBucket.bind(this));

    // Change the tile size.
    form.elements.tileSize?.addEventListener("change", this._onChangeTileSize.bind(this));

    // Activate display mode controls
    const modes = html.find(".display-modes");
    modes.on("click", ".display-mode", this.#onChangeDisplayMode.bind(this));
    for ( let li of modes[0].children ) {
      li.classList.toggle("active", li.dataset.mode === this.displayMode);
    }

    // Upload new file
    if ( this.canUpload ) form.upload.onchange = ev => this.#onUpload(ev);

    // Directory-level actions
    html.find(".directory").on("click", "li", this.#onPick.bind(this));

    // Directory-level actions
    html.find(".favorites").on("click", "a", this.#onClickFavorite.bind(this));

    // Flag the current pick
    let li = form.querySelector(`.file[data-path="${encodeURIComponent(this.request)}"]`);
    if ( li ) li.classList.add("picked");

    // Form submission
    form.onsubmit = ev => this._onSubmit(ev);

    // Intersection Observer to lazy-load images
    const files = html.find(".files-list");
    const observer = new IntersectionObserver(this.#onLazyLoadImages.bind(this), {root: files[0]});
    files.find("li.file").each((i, li) => observer.observe(li));
  }

  /* -------------------------------------------- */

  /**
   * Handle a click event to change the display mode of the File Picker
   * @param {MouseEvent} event    The triggering click event
   */
  #onChangeDisplayMode(event) {
    event.preventDefault();
    const a = event.currentTarget;
    if ( !this.constructor.DISPLAY_MODES.includes(a.dataset.mode) ) {
      throw new Error("Invalid display mode requested");
    }
    if ( a.dataset.mode === this.displayMode ) return;
    this.constructor.LAST_DISPLAY_MODE = this.displayMode = a.dataset.mode;
    this.render();
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeTab(event, tabs, active) {
    this.activeSource = active;
    this.browse(this.source.target);
  }

  /* -------------------------------------------- */

  /** @override */
  _canDragStart(selector) {
    return game.user?.isGM && (canvas.activeLayer instanceof TilesLayer);
  }

  /* -------------------------------------------- */

  /** @override */
  _canDragDrop(selector) {
    return this.canUpload;
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragStart(event) {
    const li = event.currentTarget;

    // Get the tile size ratio
    const tileSize = parseInt(li.closest("form").tileSize.value) || canvas.dimensions.size;
    const ratio = canvas.dimensions.size / tileSize;

    // Set drag data
    const dragData = {
      type: "Tile",
      texture: {src: li.dataset.path},
      fromFilePicker: true,
      tileSize
    };
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));

    // Create the drag preview for the image
    const img = li.querySelector("img");
    const w = img.naturalWidth * ratio * canvas.stage.scale.x;
    const h = img.naturalHeight * ratio * canvas.stage.scale.y;
    const preview = DragDrop.createDragImage(img, w, h);
    event.dataTransfer.setDragImage(preview, w/2, h/2);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDrop(event) {
    if ( !this.canUpload ) return;
    const form = event.currentTarget.closest("form");
    form.disabled = true;
    const target = form.target.value;

    // Process the data transfer
    const data = TextEditor.getDragEventData(event);
    const files = event.dataTransfer.files;
    if ( !files || !files.length || data.fromFilePicker ) return;

    // Iterate over dropped files
    for ( let upload of files ) {
      const name = upload.name.toLowerCase();
      try {
        this.#validateExtension(name);
      } catch(err) {
        ui.notifications.error(err, {console: true});
        continue;
      }
      const response = await this.constructor.upload(this.activeSource, target, upload, {
        bucket: form.bucket ? form.bucket.value : null
      });
      if ( response ) this.request = response.path;
    }

    // Re-enable the form
    form.disabled = false;
    return this.browse(target);
  }

  /* -------------------------------------------- */

  /**
   * Validate that the extension of the uploaded file is permitted for this file-picker instance.
   * This is an initial client-side test, the MIME type will be further checked by the server.
   * @param {string} name       The file name attempted for upload
   */
  #validateExtension(name) {
    const ext = `.${name.split(".").pop()}`;
    if ( !this.extensions.includes(ext) ) {
      const msg = game.i18n.format("FILES.ErrorDisallowedExtension", {name, ext, allowed: this.extensions.join(" ")});
      throw new Error(msg);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle user submission of the address bar to request an explicit target
   * @param {KeyboardEvent} event     The originating keydown event
   */
  #onRequestTarget(event) {
    if ( event.key === "Enter" ) {
      event.preventDefault();
      return this.browse(event.target.value);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle user interaction with the favorites
   * @param {PointerEvent} event     The originating click event
   */
  async #onClickFavorite(event) {
    const action = event.currentTarget.dataset.action;
    const source = event.currentTarget.dataset.source || this.activeSource;
    const path = event.currentTarget.dataset.path || this.target;

    switch (action) {
      case "goToFavorite":
        this.activeSource = source;
        await this.browse(path);
        break;
      case "setFavorite":
        await this.constructor.setFavorite(source, path);
        break;
      case "removeFavorite":
        await this.constructor.removeFavorite(source, path);
        break;
    }
    this.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle requests from the IntersectionObserver to lazily load an image file
   * @param {...any} args
   */
  #onLazyLoadImages(...args) {
    if ( this.displayMode === "list" ) return;
    return SidebarTab.prototype._onLazyLoadImage.call(this, ...args);
  }

  /* -------------------------------------------- */

  /**
   * Handle file or folder selection within the file picker
   * @param {Event} event     The originating click event
   */
  #onPick(event) {
    const li = event.currentTarget;
    const form = li.closest("form");
    if ( li.classList.contains("dir") ) return this.browse(li.dataset.path);
    for ( let l of li.parentElement.children ) {
      l.classList.toggle("picked", l === li);
    }
    if ( form.file ) form.file.value = li.dataset.path;
  }

  /* -------------------------------------------- */

  /**
   * Handle backwards navigation of the folder structure.
   * @param {PointerEvent} event    The triggering click event
   */
  #onClickDirectoryControl(event) {
    event.preventDefault();
    const button = event.currentTarget;
    const action = button.dataset.action;
    switch (action) {
      case "back":
        let target = this.target.split("/");
        target.pop();
        return this.browse(target.join("/"));
      case "mkdir":
        return this.#createDirectoryDialog(this.source);
      case "toggle-privacy":
        let isPrivate = !this.result.private;
        const data = {private: isPrivate, bucket: this.result.bucket};
        return this.constructor.configurePath(this.activeSource, this.target, data).then(r => {
          this.result.private = r.private;
          this.render();
        });
    }
  }

  /* -------------------------------------------- */

  /**
   * Present the user with a dialog to create a subdirectory within their currently browsed file storage location.
   * @param {object} source     The data source being browsed
   */
  #createDirectoryDialog(source) {
    const form = `<form><div class="form-group">
    <label>Directory Name</label>
    <input type="text" name="dirname" placeholder="directory-name" required/>
    </div></form>`;
    return Dialog.confirm({
      title: game.i18n.localize("FILES.CreateSubfolder"),
      content: form,
      yes: async html => {
        const dirname = html.querySelector("input").value;
        const path = [source.target, dirname].filterJoin("/");
        try {
          await this.constructor.createDirectory(this.activeSource, path, {bucket: source.bucket});
        } catch( err ) {
          ui.notifications.error(err.message);
        }
        return this.browse(this.target);
      },
      options: {jQuery: false}
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to the bucket selector
   * @param {Event} event     The S3 bucket select change event
   */
  #onChangeBucket(event) {
    event.preventDefault();
    const select = event.currentTarget;
    this.sources.s3.bucket = select.value;
    return this.browse("/");
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to the tile size.
   * @param {Event} event  The triggering event.
   * @protected
   */
  _onChangeTileSize(event) {
    this.constructor.LAST_TILE_SIZE = event.currentTarget.valueAsNumber;
  }

  /* -------------------------------------------- */

  /** @override */
  _onSearchFilter(event, query, rgx, html) {
    for ( let ol of html.querySelectorAll(".directory") ) {
      let matched = false;
      for ( let li of ol.children ) {
        let match = rgx.test(SearchFilter.cleanQuery(li.dataset.name));
        if ( match ) matched = true;
        li.style.display = !match ? "none" : "";
      }
      ol.style.display = matched ? "" : "none";
    }
    this.setPosition({height: "auto"});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onSubmit(ev) {
    ev.preventDefault();
    let path = ev.target.file.value;
    if ( !path ) return ui.notifications.error("You must select a file to proceed.");

    // Update the target field
    if ( this.field ) {
      this.field.value = path;
      this.field.dispatchEvent(new Event("change", {bubbles: true, cancelable: true}));
    }

    // Trigger a callback and close
    if ( this.callback ) this.callback(path, this);
    return this.close();
  }

  /* -------------------------------------------- */

  /**
   * Handle file upload
   * @param {Event} ev      The file upload event
   */
  async #onUpload(ev) {
    const form = ev.target.form;
    const upload = form.upload.files[0];
    const name = upload.name.toLowerCase();

    // Validate file extension
    try {
      this.#validateExtension(name);
    } catch(err) {
      ui.notifications.error(err, {console: true});
      return false;
    }

    // Dispatch the request
    const target = form.target.value;
    const options = { bucket: form.bucket ? form.bucket.value : null };
    const response = await this.constructor.upload(this.activeSource, target, upload, options);

    // Handle errors
    if ( response.error ) {
      return ui.notifications.error(response.error);
    }

    // Flag the uploaded file as the new request
    this.request = response.path;
    return this.browse(target);
  }

  /* -------------------------------------------- */
  /*  Factory Methods
  /* -------------------------------------------- */

  /**
   * Bind the file picker to a new target field.
   * Assumes the user will provide a HTMLButtonElement which has the data-target and data-type attributes
   * The data-target attribute should provide the name of the input field which should receive the selected file
   * The data-type attribute is a string in ["image", "audio"] which sets the file extensions which will be accepted
   *
   * @param {HTMLButtonElement} button     The button element
   */
  static fromButton(button) {
    if ( !(button instanceof HTMLButtonElement ) ) throw new Error("You must pass an HTML button");
    let type = button.getAttribute("data-type");
    const form = button.form;
    const field = form[button.dataset.target] || null;
    const current = field?.value || "";
    return new FilePicker({field, type, current, button});
  }
}

/**
 * @typedef {object} SearchFilterConfiguration
 * @property {object} options          Options which customize the behavior of the filter
 * @property {string} options.inputSelector    The CSS selector used to target the text input element.
 * @property {string} options.contentSelector  The CSS selector used to target the content container for these tabs.
 * @property {Function} options.callback       A callback function which executes when the filter changes.
 * @property {string} [options.initial]        The initial value of the search query.
 * @property {number} [options.delay=200]      The number of milliseconds to wait for text input before processing.
 */

/**
 * @typedef {object} FieldFilter
 * @property {string} field                                     The dot-delimited path to the field being filtered
 * @property {string} [operator=SearchFilter.OPERATORS.EQUALS]  The search operator, from CONST.OPERATORS
 * @property {boolean} negate                                   Negate the filter, returning results which do NOT match the filter criteria
 * @property {*} value                                          The value against which to test
 */

/**
 * A controller class for managing a text input widget that filters the contents of some other UI element
 * @see {@link Application}
 *
 * @param {SearchFilterConfiguration}
 */
class SearchFilter {

  /**
   * The allowed Filter Operators which can be used to define a search filter
   * @enum {string}
   */
  static OPERATORS = Object.freeze({
    EQUALS: "equals",
    CONTAINS: "contains",
    STARTS_WITH: "starts_with",
    ENDS_WITH: "ends_with",
    LESS_THAN: "lt",
    LESS_THAN_EQUAL: "lte",
    GREATER_THAN: "gt",
    GREATER_THAN_EQUAL: "gte",
    BETWEEN: "between",
    IS_EMPTY: "is_empty",
  });


  // Average typing speed is 167 ms per character, per https://stackoverflow.com/a/4098779
  constructor({inputSelector, contentSelector, initial="", callback, delay=200}={}) {

    /**
     * The value of the current query string
     * @type {string}
     */
    this.query = initial;

    /**
     * A callback function to trigger when the tab is changed
     * @type {Function|null}
     */
    this.callback = callback;

    /**
     * The regular expression corresponding to the query that should be matched against
     * @type {RegExp}
     */
    this.rgx = undefined;

    /**
     * The CSS selector used to target the tab navigation element
     * @type {string}
     */
    this._inputSelector = inputSelector;

    /**
     * A reference to the HTML navigation element the tab controller is bound to
     * @type {HTMLElement|null}
     */
    this._input = null;

    /**
     * The CSS selector used to target the tab content element
     * @type {string}
     */
    this._contentSelector = contentSelector;

    /**
     * A reference to the HTML container element of the tab content
     * @type {HTMLElement|null}
     */
    this._content = null;

    /**
     * A debounced function which applies the search filtering
     * @type {Function}
     */
    this._filter = foundry.utils.debounce(this.callback, delay);
  }


  /* -------------------------------------------- */

  /**
   * Test whether a given object matches a provided filter
   * @param {object} obj          An object to test against
   * @param {FieldFilter} filter  The filter to test
   * @returns {boolean}           Whether the object matches the filter
   */
  static evaluateFilter(obj, filter) {
    const docValue = foundry.utils.getProperty(obj, filter.field);
    const filterValue = filter.value;

    function _evaluate() {
      switch (filter.operator) {
        case SearchFilter.OPERATORS.EQUALS:
          if ( docValue.equals instanceof Function ) return docValue.equals(filterValue);
          else return (docValue === filterValue);
        case SearchFilter.OPERATORS.CONTAINS:
          if ( Array.isArray(filterValue) )
            return filterValue.includes(docValue);
          else
            return [filterValue].includes(docValue);
        case SearchFilter.OPERATORS.STARTS_WITH:
          return docValue.startsWith(filterValue);
        case SearchFilter.OPERATORS.ENDS_WITH:
          return docValue.endsWith(filterValue);
        case SearchFilter.OPERATORS.LESS_THAN:
          return (docValue < filterValue);
        case SearchFilter.OPERATORS.LESS_THAN_EQUAL:
          return (docValue <= filterValue);
        case SearchFilter.OPERATORS.GREATER_THAN:
          return (docValue > filterValue);
        case SearchFilter.OPERATORS.GREATER_THAN_EQUAL:
          return (docValue >= filterValue);
        case SearchFilter.OPERATORS.BETWEEN:
          if ( !Array.isArray(filterValue) || filterValue.length !== 2 ) {
            throw new Error(`Invalid filter value for ${filter.operator} operator. Expected an array of length 2.`);
          }
          const [min, max] = filterValue;
          return (docValue >= min) && (docValue <= max);
        case SearchFilter.OPERATORS.IS_EMPTY:
          return foundry.utils.isEmpty(docValue);
        default:
          return (docValue === filterValue);
      }
    }

    const result = _evaluate();
    return filter.negate ? !result : result;
  }

  /* -------------------------------------------- */

  /**
   * Bind the SearchFilter controller to an HTML application
   * @param {HTMLElement} html
   */
  bind(html) {

    // Identify navigation element
    this._input = html.querySelector(this._inputSelector);
    if ( !this._input ) return;
    this._input.value = this.query;

    // Identify content container
    if ( !this._contentSelector ) this._content = null;
    else if ( html.matches(this._contentSelector) ) this._content = html;
    else this._content = html.querySelector(this._contentSelector);

    // Register the handler for input changes
    // Use the input event which also captures clearing the filter
    // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event
    this._input.addEventListener("input", event => {
      event.preventDefault();
      this.filter(event, event.currentTarget.value);
    });

    // Apply the initial filtering conditions
    const event = new KeyboardEvent("input", {key: "Enter", code: "Enter"});
    this.filter(event, this.query);
  }

  /* -------------------------------------------- */

  /**
   * Perform a filtering of the content by invoking the callback function
   * @param {KeyboardEvent} event   The triggering keyboard event
   * @param {string} query          The input search string
   */
  filter(event, query) {
    this.query = SearchFilter.cleanQuery(query);
    this.rgx = new RegExp(RegExp.escape(this.query), "i");
    this._filter(event, this.query, this.rgx, this._content);
  }

  /* -------------------------------------------- */

  /**
   * Clean a query term to standardize it for matching.
   * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize
   * @param {string} query    An input string which may contain leading/trailing spaces or diacritics
   * @returns {string}        A cleaned string of ASCII characters for comparison
   */
  static cleanQuery(query) {
    return query.trim().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
  }
}

/**
 * An extension of the native FormData implementation.
 *
 * This class functions the same way that the default FormData does, but it is more opinionated about how
 * input fields of certain types should be evaluated and handled.
 *
 * It also adds support for certain Foundry VTT specific concepts including:
 *  Support for defined data types and type conversion
 *  Support for TinyMCE editors
 *  Support for editable HTML elements
 *
 * @extends {FormData}
 *
 * @param {HTMLFormElement} form          The form being processed
 * @param {object} options                Options which configure form processing
 * @param {Record<string, object>} [options.editors]      A record of TinyMCE editor metadata objects, indexed by their update key
 * @param {Record<string, string>} [options.dtypes]       A mapping of data types for form fields
 * @param {boolean} [options.disabled=false]      Include disabled fields?
 * @param {boolean} [options.readonly=false]      Include readonly fields?
 */
class FormDataExtended extends FormData {
  constructor(form, {dtypes={}, editors={}, disabled=false, readonly=true}={}) {
    super();

    /**
     * A mapping of data types requested for each form field.
     * @type {{string, string}}
     */
    this.dtypes = dtypes;

    /**
     * A record of TinyMCE editors which are linked to this form.
     * @type {Record<string, object>}
     */
    this.editors = editors;

    /**
     * The object representation of the form data, available once processed.
     * @type {object}
     */
    Object.defineProperty(this, "object", {value: {}, writable: false, enumerable: false});

    // Process the provided form
    this.process(form, {disabled, readonly});
  }

  /* -------------------------------------------- */

  /**
   * Process the HTML form element to populate the FormData instance.
   * @param {HTMLFormElement} form    The HTML form being processed
   * @param {object} options          Options forwarded from the constructor
   */
  process(form, options) {
    this.#processFormFields(form, options);
    this.#processEditableHTML(form, options);
    this.#processEditors();

    // Emit the formdata event for compatibility with the parent FormData class
    form.dispatchEvent(new FormDataEvent("formdata", {formData: this}));
  }

  /* -------------------------------------------- */

  /**
   * Assign a value to the FormData instance which always contains JSON strings.
   * Also assign the cast value in its preferred data type to the parsed object representation of the form data.
   * @param {string} name     The field name
   * @param {any} value       The raw extracted value from the field
   * @inheritDoc
   */
  set(name, value) {
    this.object[name] = value;
    if ( value instanceof Array ) value = JSON.stringify(value);
    super.set(name, value);
  }

  /* -------------------------------------------- */

  /**
   * Append values to the form data, adding them to an array.
   * @param {string} name     The field name to append to the form
   * @param {any} value       The value to append to the form data
   * @inheritDoc
   */
  append(name, value) {
    if ( name in this.object ) {
      if ( !Array.isArray(this.object[name]) ) this.object[name] = [this.object[name]];
    }
    else this.object[name] = [];
    this.object[name].push(value);
    super.append(name, value);
  }

  /* -------------------------------------------- */

  /**
   * Process all standard HTML form field elements from the form.
   * @param {HTMLFormElement} form    The form being processed
   * @param {object} options          Options forwarded from the constructor
   * @param {boolean} [options.disabled]    Process disabled fields?
   * @param {boolean} [options.readonly]    Process readonly fields?
   * @private
   */
  #processFormFields(form, {disabled, readonly}={}) {
    if ( !disabled && form.hasAttribute("disabled") ) return;
    const mceEditorIds = Object.values(this.editors).map(e => e.mce?.id);
    for ( const element of form.elements ) {
      const name = element.name;

      // Skip fields which are unnamed or already handled
      if ( !name || this.has(name) ) continue;

      // Skip buttons and editors
      if ( (element.tagName === "BUTTON") || mceEditorIds.includes(name) ) continue;

      // Skip disabled or read-only fields
      if ( !disabled && (element.disabled || element.closest("fieldset")?.disabled) ) continue;
      if ( !readonly && element.readOnly ) continue;

      // Extract and process the value of the field
      const field = form.elements[name];
      const value = this.#getFieldValue(name, field);
      this.set(name, value);
    }
  }

  /* -------------------------------------------- */

  /**
   * Process editable HTML elements (ones with a [data-edit] attribute).
   * @param {HTMLFormElement} form    The form being processed
   * @param {object} options          Options forwarded from the constructor
   * @param {boolean} [options.disabled]    Process disabled fields?
   * @param {boolean} [options.readonly]    Process readonly fields?
   * @private
   */
  #processEditableHTML(form, {disabled, readonly}={}) {
    const editableElements = form.querySelectorAll("[data-edit]");
    for ( const element of editableElements ) {
      const name = element.dataset.edit;
      if ( this.has(name) || (name in this.editors) ) continue;
      if ( (!disabled && element.disabled) || (!readonly && element.readOnly) ) continue;
      let value;
      if (element.tagName === "IMG") value = element.getAttribute("src");
      else value = element.innerHTML.trim();
      this.set(name, value);
    }
  }

  /* -------------------------------------------- */

  /**
   * Process TinyMCE editor instances which are present in the form.
   * @private
   */
  #processEditors() {
    for ( const [name, editor] of Object.entries(this.editors) ) {
      if ( !editor.instance ) continue;
      if ( editor.options.engine === "tinymce" ) {
        const content = editor.instance.getContent();
        this.delete(editor.mce.id); // Delete hidden MCE inputs
        this.set(name, content);
      } else if ( editor.options.engine === "prosemirror" ) {
        this.set(name, ProseMirror.dom.serializeString(editor.instance.view.state.doc.content));
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Obtain the parsed value of a field conditional on its element type and requested data type.
   * @param {string} name                       The field name being processed
   * @param {HTMLElement|RadioNodeList} field   The HTML field or a RadioNodeList of multiple fields
   * @returns {*}                               The processed field value
   * @private
   */
  #getFieldValue(name, field) {

    // Multiple elements with the same name
    if ( field instanceof RadioNodeList ) {
      const fields = Array.from(field);
      if ( fields.every(f => f.type === "radio") ) {
        const chosen = fields.find(f => f.checked);
        return chosen ? this.#getFieldValue(name, chosen) : undefined;
      }
      return Array.from(field).map(f => this.#getFieldValue(name, f));
    }

    // Record requested data type
    const dataType = field.dataset.dtype || this.dtypes[name];

    // Checkbox
    if ( field.type === "checkbox" ) {

      // Non-boolean checkboxes with an explicit value attribute yield that value or null
      if ( field.hasAttribute("value") && (dataType !== "Boolean") ) {
        return this.#castType(field.checked ? field.value : null, dataType);
      }

      // Otherwise, true or false based on the checkbox checked state
      return this.#castType(field.checked, dataType);
    }

    // Number and Range
    if ( ["number", "range"].includes(field.type) ) {
      if ( field.value === "" ) return null;
      else return this.#castType(field.value, dataType || "Number");
    }

    // Multi-Select
    if ( field.type === "select-multiple" ) {
      return Array.from(field.options).reduce((chosen, opt) => {
        if ( opt.selected ) chosen.push(this.#castType(opt.value, dataType));
        return chosen;
      }, []);
    }

    // Radio Select
    if ( field.type === "radio" ) {
      return field.checked ? this.#castType(field.value, dataType) : null;
    }

    // Other field types
    return this.#castType(field.value, dataType);
  }

  /* -------------------------------------------- */

  /**
   * Cast a processed value to a desired data type.
   * @param {any} value         The raw field value
   * @param {string} dataType   The desired data type
   * @returns {any}             The resulting data type
   * @private
   */
  #castType(value, dataType) {
    if ( value instanceof Array ) return value.map(v => this.#castType(v, dataType));
    if ( [undefined, null].includes(value) || (dataType === "String") ) return value;

    // Boolean
    if ( dataType === "Boolean" ) {
      if ( value === "false" ) return false;
      return Boolean(value);
    }

    // Number
    else if ( dataType === "Number" ) {
      if ( (value === "") || (value === "null") ) return null;
      return Number(value);
    }

    // Serialized JSON
    else if ( dataType === "JSON" ) {
      return JSON.parse(value);
    }

    // Other data types
    if ( window[dataType] instanceof Function ) {
      try {
        return window[dataType](value);
      } catch(err) {
        console.warn(`The form field value "${value}" was not able to be cast to the requested data type ${dataType}`);
      }
    }
    return value;
  }
}

/**
 * A common framework for displaying notifications to the client.
 * Submitted notifications are added to a queue, and up to 3 notifications are displayed at once.
 * Each notification is displayed for 5 seconds at which point further notifications are pulled from the queue.
 *
 * @extends {Application}
 *
 * @example Displaying Notification Messages
 * ```js
 * ui.notifications.info("This is an info message");
 * ui.notifications.warn("This is a warning message");
 * ui.notifications.error("This is an error message");
 * ui.notifications.info("This is a 4th message which will not be shown until the first info message is done");
 * ```
 */
class Notifications extends Application {
  /**
   * An incrementing counter for the notification IDs.
   * @type {number}
   */
  #id = 1;

  constructor(options) {
    super(options);

    /**
     * Submitted notifications which are queued for display
     * @type {object[]}
     */
    this.queue = [];

    /**
     * Notifications which are currently displayed
     * @type {object[]}
     */
    this.active = [];

    // Initialize any pending messages
    this.initialize();
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      popOut: false,
      id: "notifications",
      template: "templates/hud/notifications.html"
    });
  }

  /* -------------------------------------------- */

  /**
   * Initialize the Notifications system by displaying any system-generated messages which were passed from the server.
   */
  initialize() {
    for ( let m of globalThis.MESSAGES ) {
      this.notify(game.i18n.localize(m.message), m.type, m.options);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _renderInner(...args) {
    return $('<ol id="notifications"></ol>');
  }

  /* -------------------------------------------- */

  /** @override */
  async _render(...args) {
    await super._render(...args);
    while ( this.queue.length && (this.active.length < 3) ) this.fetch();
  }

  /* -------------------------------------------- */

  /**
   * @typedef {Object} NotifyOptions
   * @property {boolean} [permanent=false]      Should the notification be permanently displayed until dismissed
   * @property {boolean} [localize=false]       Whether to localize the message content before displaying it
   * @property {boolean} [console=true]         Whether to log the message to the console
   */

  /**
   * Push a new notification into the queue
   * @param {string} message                   The content of the notification message
   * @param {string} type                      The type of notification, "info", "warning", and "error" are supported
   * @param {NotifyOptions} [options={}]       Additional options which affect the notification
   * @returns {number}                         The ID of the notification (positive integer)
   */
  notify(message, type="info", {localize=false, permanent=false, console=true}={}) {
    if ( localize ) message = game.i18n.localize(message);
    let n = {
      id: this.#id++,
      message: message,
      type: ["info", "warning", "error"].includes(type) ? type : "info",
      timestamp: new Date().getTime(),
      permanent: permanent,
      console: console
    };
    this.queue.push(n);
    if ( this.rendered ) this.fetch();
    return n.id;
  }

  /* -------------------------------------------- */

  /**
   * Display a notification with the "info" type
   * @param {string} message           The content of the notification message
   * @param {NotifyOptions} options    Notification options passed to the notify function
   * @returns {number}                 The ID of the notification (positive integer)
   */
  info(message, options) {
    return this.notify(message, "info", options);
  }

  /* -------------------------------------------- */

  /**
   * Display a notification with the "warning" type
   * @param {string} message           The content of the notification message
   * @param {NotifyOptions} options    Notification options passed to the notify function
   * @returns {number}                 The ID of the notification (positive integer)
   */
  warn(message, options) {
    return this.notify(message, "warning", options);
  }

  /* -------------------------------------------- */

  /**
   * Display a notification with the "error" type
   * @param {string} message           The content of the notification message
   * @param {NotifyOptions} options    Notification options passed to the notify function
   * @returns {number}                 The ID of the notification (positive integer)
   */
  error(message, options) {
    return this.notify(message, "error", options);
  }

  /* -------------------------------------------- */

  /**
   * Remove the notification linked to the ID.
   * @param {number} id                 The ID of the notification
   */
  remove(id) {
    if ( !(id > 0) ) return;

    // Remove a queued notification that has not been displayed yet
    const queued = this.queue.findSplice(n => n.id === id);
    if ( queued ) return;

    // Remove an active HTML element
    const active = this.active.findSplice(li => li.data("id") === id);
    if ( !active ) return;
    active.fadeOut(66, () => active.remove());
    this.fetch();
  }

  /* -------------------------------------------- */

  /**
   * Clear all notifications.
   */
  clear() {
    this.queue.length = 0;
    for ( const li of this.active ) li.fadeOut(66, () => li.remove());
    this.active.length = 0;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a pending notification from the queue and display it
   * @private
   * @returns {void}
   */
  fetch() {
    if ( !this.queue.length || (this.active.length >= 3) ) return;
    const next = this.queue.pop();
    const now = Date.now();

    // Define the function to remove the notification
    const _remove = li => {
      li.fadeOut(66, () => li.remove());
      const i = this.active.indexOf(li);
      if ( i >= 0 ) this.active.splice(i, 1);
      return this.fetch();
    };

    // Construct a new notification
    const cls = ["notification", next.type, next.permanent ? "permanent" : null].filterJoin(" ");
    const li = $(`<li class="${cls}" data-id="${next.id}">${next.message}<i class="close fas fa-times-circle"></i></li>`);

    // Add click listener to dismiss
    li.click(ev => {
      if ( Date.now() - now > 250 ) _remove(li);
    });
    this.element.prepend(li);
    li.hide().slideDown(132);
    this.active.push(li);

    // Log to console if enabled
    if ( next.console ) console[next.type === "warning" ? "warn" : next.type](next.message);

    // Schedule clearing the notification 5 seconds later
    if ( !next.permanent ) window.setTimeout(() => _remove(li), 5000);
  }
}

/**
 * @typedef {object} ProseMirrorHistory
 * @property {string} userId  The ID of the user who submitted the step.
 * @property {Step} step      The step that was submitted.
 */

/**
 * A class responsible for managing state and collaborative editing of a single ProseMirror instance.
 */
class ProseMirrorEditor {
  /**
   * @param {string} uuid                        A string that uniquely identifies this ProseMirror instance.
   * @param {EditorView} view                    The ProseMirror EditorView.
   * @param {Plugin} isDirtyPlugin               The plugin to track the dirty state of the editor.
   * @param {boolean} collaborate                Whether this is a collaborative editor.
   * @param {object} [options]                   Additional options.
   * @param {ClientDocument} [options.document]  A document associated with this editor.
   */
  constructor(uuid, view, isDirtyPlugin, collaborate, options={}) {
    /**
     * A string that uniquely identifies this ProseMirror instance.
     * @type {string}
     */
    Object.defineProperty(this, "uuid", {value: uuid, writable: false});

    /**
     * The ProseMirror EditorView.
     * @type {EditorView}
     */
    Object.defineProperty(this, "view", {value: view, writable: false});

    /**
     * Whether this is a collaborative editor.
     * @type {boolean}
     */
    Object.defineProperty(this, "collaborate", {value: collaborate, writable: false});

    this.options = options;
    this.#isDirtyPlugin = isDirtyPlugin;
  }

  /* -------------------------------------------- */

  /**
   * A list of active editor instances by their UUIDs.
   * @type {Map<string, ProseMirrorEditor>}
   */
  static #editors = new Map();

  /* -------------------------------------------- */

  /**
   * The plugin to track the dirty state of the editor.
   * @type {Plugin}
   */
  #isDirtyPlugin;

  /* -------------------------------------------- */

  /**
   * Retire this editor instance and clean up.
   */
  destroy() {
    ProseMirrorEditor.#editors.delete(this.uuid);
    this.view.destroy();
    if ( this.collaborate ) game.socket.emit("pm.endSession", this.uuid);
  }

  /* -------------------------------------------- */

  /**
   * Have the contents of the editor been edited by the user?
   * @returns {boolean}
   */
  isDirty() {
    return this.#isDirtyPlugin.getState(this.view.state);
  }

  /* -------------------------------------------- */

  /**
   * Handle new editing steps supplied by the server.
   * @param {string} offset                 The offset into the history, representing the point at which it was last
   *                                        truncated.
   * @param {ProseMirrorHistory[]} history  The entire edit history.
   * @protected
   */
  _onNewSteps(offset, history) {
    this._disableSourceCodeEditing();
    this.options.document?.sheet?.onNewSteps?.();
    const version = ProseMirror.collab.getVersion(this.view.state);
    const newSteps = history.slice(version - offset);

    // Flatten out the data into a format that ProseMirror.collab.receiveTransaction can understand.
    const [steps, ids] = newSteps.reduce(([steps, ids], entry) => {
      steps.push(ProseMirror.Step.fromJSON(ProseMirror.defaultSchema, entry.step));
      ids.push(entry.userId);
      return [steps, ids];
    }, [[], []]);

    const tr = ProseMirror.collab.receiveTransaction(this.view.state, steps, ids);
    this.view.dispatch(tr);
  }

  /* -------------------------------------------- */

  /**
   * Disable source code editing if the user was editing it when new steps arrived.
   * @protected
   */
  _disableSourceCodeEditing() {
    const textarea = this.view.dom.closest(".editor")?.querySelector(":scope > textarea");
    if ( !textarea ) return;
    textarea.disabled = true;
    ui.notifications.warn("EDITOR.EditingHTMLWarning", {localize: true, permanent: true});
  }

  /* -------------------------------------------- */

  /**
   * The state of this ProseMirror editor has fallen too far behind the central authority's and must be re-synced.
   * @protected
   */
  _resync() {
    // Copy the editor's current state to the clipboard to avoid data loss.
    const existing = this.view.dom;
    existing.contentEditable = false;
    const selection = document.getSelection();
    selection.removeAllRanges();
    const range = document.createRange();
    range.selectNode(existing);
    selection.addRange(range);
    // We cannot use navigator.clipboard.write here as it is disabled or not fully implemented in some browsers.
    // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Interact_with_the_clipboard
    document.execCommand("copy");
    ui.notifications.warn("EDITOR.Resync", {localize: true, permanent: true});
    this.destroy();
    this.options.document?.sheet?.render(true, {resync: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle users joining or leaving collaborative editing.
   * @param {string[]} users  The IDs of users currently editing (including ourselves).
   * @protected
   */
  _updateUserDisplay(users) {
    const editor = this.view.dom.closest(".editor");
    editor.classList.toggle("collaborating", users.length > 1);
    const pips = users.map(id => {
      const user = game.users.get(id);
      if ( !user ) return "";
      return `
        <span class="scene-player" style="background: ${user.color}; border: 1px solid ${user.border.css};">
          ${user.name[0]}
        </span>
      `;
    }).join("");
    const collaborating = editor.querySelector("menu .concurrent-users");
    collaborating.dataset.tooltip = users.map(id => game.users.get(id)?.name).join(", ");
    collaborating.innerHTML = `
      <i class="fa-solid fa-user-group"></i>
      ${pips}
    `;
  }

  /* -------------------------------------------- */

  /**
   * Handle an autosave update for an already-open editor.
   * @param {string} html  The updated editor contents.
   * @protected
   */
  _handleAutosave(html) {
    this.options.document?.sheet?.onAutosave?.(html);
  }

  /* -------------------------------------------- */

  /**
   * Create a ProseMirror editor instance.
   * @param {HTMLElement} target                     An HTML element to mount the editor to.
   * @param {string} [content=""]                    Content to populate the editor with.
   * @param {object} [options]                       Additional options to configure the ProseMirror instance.
   * @param {string} [options.uuid]                  A string to uniquely identify this ProseMirror instance. Ignored
   *                                                 for a collaborative editor.
   * @param {ClientDocument} [options.document]      A Document whose content is being edited. Required for
   *                                                 collaborative editing and relative UUID generation.
   * @param {string} [options.fieldName]             The field within the Document that is being edited. Required for
   *                                                 collaborative editing.
   * @param {Record<string, Plugin>} [options.plugins]       Plugins to include with the editor.
   * @param {boolean} [options.relativeLinks=false]  Whether to generate relative UUID links to Documents that are
   *                                                 dropped on the editor.
   * @param {boolean} [options.collaborate=false]    Whether to enable collaborative editing for this editor.
   * @returns {Promise<ProseMirrorEditor>}
   */
  static async create(target, content="", {uuid, document, fieldName, plugins={}, collaborate=false,
    relativeLinks=false}={}) {

    if ( collaborate && (!document || !fieldName) ) {
      throw new Error("A document and fieldName must be provided when creating an editor with collaborative editing.");
    }

    uuid = collaborate ? `${document.uuid}#${fieldName}` : uuid ?? `ProseMirror.${foundry.utils.randomID()}`;
    const state = ProseMirror.EditorState.create({doc: ProseMirror.dom.parseString(content)});
    plugins = Object.assign({}, ProseMirror.defaultPlugins, plugins);
    plugins.contentLinks = ProseMirror.ProseMirrorContentLinkPlugin.build(ProseMirror.defaultSchema, {
      document, relativeLinks
    });

    if ( document ) {
      plugins.images = ProseMirror.ProseMirrorImagePlugin.build(ProseMirror.defaultSchema, {document});
    }

    const options = {state};
    Hooks.callAll("createProseMirrorEditor", uuid, plugins, options);

    const view = collaborate
      ? await this._createCollaborativeEditorView(uuid, target, options.state, Object.values(plugins))
      : this._createLocalEditorView(target, options.state, Object.values(plugins));
    const editor = new ProseMirrorEditor(uuid, view, plugins.isDirty, collaborate, {document});
    ProseMirrorEditor.#editors.set(uuid, editor);
    return editor;
  }

  /* -------------------------------------------- */

  /**
   * Create an EditorView with collaborative editing enabled.
   * @param {string} uuid         The ProseMirror instance UUID.
   * @param {HTMLElement} target  An HTML element to mount the editor view to.
   * @param {EditorState} state   The ProseMirror editor state.
   * @param {Plugin[]} plugins    The editor plugins to load.
   * @returns {Promise<EditorView>}
   * @protected
   */
  static async _createCollaborativeEditorView(uuid, target, state, plugins) {
    const authority = await new Promise((resolve, reject) => {
      game.socket.emit("pm.editDocument", uuid, state, authority => {
        if ( authority.state ) resolve(authority);
        else reject();
      });
    });
    return new ProseMirror.EditorView({mount: target}, {
      state: ProseMirror.EditorState.fromJSON({
        schema: ProseMirror.defaultSchema,
        plugins: [
          ...plugins,
          ProseMirror.collab.collab({version: authority.version, clientID: game.userId})
        ]
      }, authority.state),
      dispatchTransaction(tr) {
        const newState = this.state.apply(tr);
        this.updateState(newState);
        const sendable = ProseMirror.collab.sendableSteps(newState);
        if ( sendable ) game.socket.emit("pm.receiveSteps", uuid, sendable.version, sendable.steps);
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Create a plain EditorView without collaborative editing.
   * @param {HTMLElement} target  An HTML element to mount the editor view to.
   * @param {EditorState} state   The ProseMirror editor state.
   * @param {Plugin[]} plugins    The editor plugins to load.
   * @returns {EditorView}
   * @protected
   */
  static _createLocalEditorView(target, state, plugins) {
    return new ProseMirror.EditorView({mount: target}, {
      state: ProseMirror.EditorState.create({doc: state.doc, plugins})
    });
  }

  /* -------------------------------------------- */
  /*  Socket Handlers                             */
  /* -------------------------------------------- */

  /**
   * Handle new editing steps supplied by the server.
   * @param {string} uuid                   The UUID that uniquely identifies the ProseMirror instance.
   * @param {number} offset                 The offset into the history, representing the point at which it was last
   *                                        truncated.
   * @param {ProseMirrorHistory[]} history  The entire edit history.
   * @protected
   */
  static _onNewSteps(uuid, offset, history) {
    const editor = ProseMirrorEditor.#editors.get(uuid);
    if ( editor ) editor._onNewSteps(offset, history);
    else {
      console.warn(`New steps were received for UUID '${uuid}' which is not a ProseMirror editor instance.`);
    }
  }

  /* -------------------------------------------- */

  /**
   * Our client is too far behind the central authority's state and must be re-synced.
   * @param {string} uuid  The UUID that uniquely identifies the ProseMirror instance.
   * @protected
   */
  static _onResync(uuid) {
    const editor = ProseMirrorEditor.#editors.get(uuid);
    if ( editor ) editor._resync();
    else {
      console.warn(`A resync request was received for UUID '${uuid}' which is not a ProseMirror editor instance.`);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle users joining or leaving collaborative editing.
   * @param {string} uuid       The UUID that uniquely identifies the ProseMirror instance.
   * @param {string[]} users    The IDs of the users editing (including ourselves).
   * @protected
   */
  static _onUsersEditing(uuid, users) {
    const editor = ProseMirrorEditor.#editors.get(uuid);
    if ( editor ) editor._updateUserDisplay(users);
    else {
      console.warn(`A user update was received for UUID '${uuid}' which is not a ProseMirror editor instance.`);
    }
  }

  /* -------------------------------------------- */

  /**
   * Update client state when the editor contents are autosaved server-side.
   * @param {string} uuid  The UUID that uniquely identifies the ProseMirror instance.
   * @param {string} html  The updated editor contents.
   * @protected
   */
  static async _onAutosave(uuid, html) {
    const editor = ProseMirrorEditor.#editors.get(uuid);
    const [docUUID, field] = uuid.split("#");
    const doc = await fromUuid(docUUID);
    if ( doc ) doc.updateSource({[field]: html});
    if ( editor ) editor._handleAutosave(html);
    else doc.render(false);
  }

  /* -------------------------------------------- */

  /**
   * Listen for ProseMirror collaboration events.
   * @param {Socket} socket  The open websocket.
   * @internal
   */
  static _activateSocketListeners(socket) {
    socket.on("pm.newSteps", this._onNewSteps.bind(this));
    socket.on("pm.resync", this._onResync.bind(this));
    socket.on("pm.usersEditing", this._onUsersEditing.bind(this));
    socket.on("pm.autosave", this._onAutosave.bind(this));
  }
}

/**
 * @callback HTMLSecretContentCallback
 * @param {HTMLElement} secret  The secret element whose surrounding content we wish to retrieve.
 * @returns {string}            The content where the secret is housed.
 */

/**
 * @callback HTMLSecretUpdateCallback
 * @param {HTMLElement} secret         The secret element that is being manipulated.
 * @param {string} content             The content block containing the updated secret element.
 * @returns {Promise<ClientDocument>}  The updated Document.
 */

/**
 * @typedef {object} HTMLSecretConfiguration
 * @property {string} parentSelector      The CSS selector used to target content that contains secret blocks.
 * @property {{
 *   content: HTMLSecretContentCallback,
 *   update: HTMLSecretUpdateCallback
 * }} callbacks                           An object of callback functions for each operation.
 */

/**
 * A composable class for managing functionality for secret blocks within DocumentSheets.
 * @see {@link DocumentSheet}
 * @example Activate secret revealing functionality within a certain block of content.
 * ```js
 * const secrets = new HTMLSecret({
 *   selector: "section.secret[id]",
 *   callbacks: {
 *     content: this._getSecretContent.bind(this),
 *     update: this._updateSecret.bind(this)
 *   }
 * });
 * secrets.bind(html);
 * ```
 */
class HTMLSecret {
  /**
   * @param {HTMLSecretConfiguration} config  Configuration options.
   */
  constructor({parentSelector, callbacks={}}={}) {
    /**
     * The CSS selector used to target secret blocks.
     * @type {string}
     */
    Object.defineProperty(this, "parentSelector", {value: parentSelector, writable: false});

    /**
     * An object of callback functions for each operation.
     * @type {{content: HTMLSecretContentCallback, update: HTMLSecretUpdateCallback}}
     */
    Object.defineProperty(this, "callbacks", {value: Object.freeze(callbacks), writable: false});
  }

  /* -------------------------------------------- */

  /**
   * Add event listeners to the targeted secret blocks.
   * @param {HTMLElement} html  The HTML content to select secret blocks from.
   */
  bind(html) {
    if ( !this.callbacks.content || !this.callbacks.update ) return;
    const parents = html.querySelectorAll(this.parentSelector);
    for ( const parent of parents ) {
      parent.querySelectorAll("section.secret[id]").forEach(secret => {
        // Do not add reveal blocks to secrets inside @Embeds as they do not currently work.
        if ( secret.closest("[data-content-embed]") ) return;
        const revealed = secret.classList.contains("revealed");
        const reveal = document.createElement("button");
        reveal.type = "button";
        reveal.classList.add("reveal");
        reveal.textContent = game.i18n.localize(`EDITOR.${revealed ? "Hide" : "Reveal"}`);
        secret.insertBefore(reveal, secret.firstChild);
        reveal.addEventListener("click", this._onToggleSecret.bind(this));
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a secret's revealed state.
   * @param {MouseEvent} event           The triggering click event.
   * @returns {Promise<ClientDocument>}  The Document whose content was modified.
   * @protected
   */
  _onToggleSecret(event) {
    event.preventDefault();
    const secret = event.currentTarget.closest(".secret");
    const id = secret?.id;
    if ( !id ) return;
    const content = this.callbacks.content(secret);
    if ( !content ) return;
    const revealed = secret.classList.contains("revealed");
    const modified = content.replace(new RegExp(`<section[^i]+id="${id}"[^>]*>`), () => {
      return `<section class="secret${revealed ? "" : " revealed"}" id="${id}">`;
    });
    return this.callbacks.update(secret, modified);
  }
}

/**
 * @typedef {object} TabsConfiguration
 * @property {string} [group]            The name of the tabs group
 * @property {string} navSelector        The CSS selector used to target the navigation element for these tabs
 * @property {string} contentSelector    The CSS selector used to target the content container for these tabs
 * @property {string} initial            The tab name of the initially active tab
 * @property {Function|null} [callback]  An optional callback function that executes when the active tab is changed
 */

/**
 * A controller class for managing tabbed navigation within an Application instance.
 * @see {@link Application}
 * @param {TabsConfiguration} config    The Tabs Configuration to use for this tabbed container
 *
 * @example Configure tab-control for a set of HTML elements
 * ```html
 * <!-- Example HTML -->
 * <nav class="tabs" data-group="primary-tabs">
 *   <a class="item" data-tab="tab1" data-group="primary-tabs">Tab 1</li>
 *   <a class="item" data-tab="tab2" data-group="primary-tabs">Tab 2</li>
 * </nav>
 *
 * <section class="content">
 *   <div class="tab" data-tab="tab1" data-group="primary-tabs">Content 1</div>
 *   <div class="tab" data-tab="tab2" data-group="primary-tabs">Content 2</div>
 * </section>
 * ```
 * Activate tab control in JavaScript
 * ```js
 * const tabs = new Tabs({navSelector: ".tabs", contentSelector: ".content", initial: "tab1"});
 * tabs.bind(html);
 * ```
 */
class Tabs {
  constructor({group, navSelector, contentSelector, initial, callback}={}) {

    /**
     * The name of the tabs group
     * @type {string}
     */
    this.group = group;

    /**
     * The value of the active tab
     * @type {string}
     */
    this.active = initial;

    /**
     * A callback function to trigger when the tab is changed
     * @type {Function|null}
     */
    this.callback = callback ?? null;

    /**
     * The CSS selector used to target the tab navigation element
     * @type {string}
     */
    this._navSelector = navSelector;

    /**
     * A reference to the HTML navigation element the tab controller is bound to
     * @type {HTMLElement|null}
     */
    this._nav = null;

    /**
     * The CSS selector used to target the tab content element
     * @type {string}
     */
    this._contentSelector = contentSelector;

    /**
     * A reference to the HTML container element of the tab content
     * @type {HTMLElement|null}
     */
    this._content = null;
  }

  /* -------------------------------------------- */

  /**
   * Bind the Tabs controller to an HTML application
   * @param {HTMLElement} html
   */
  bind(html) {

    // Identify navigation element
    this._nav = html.querySelector(this._navSelector);
    if ( !this._nav ) return;

    // Identify content container
    if ( !this._contentSelector ) this._content = null;
    else if ( html.matches(this._contentSelector )) this._content = html;
    else this._content = html.querySelector(this._contentSelector);

    // Initialize the active tab
    this.activate(this.active);

    // Register listeners
    this._nav.addEventListener("click", this._onClickNav.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Activate a new tab by name
   * @param {string} tabName
   * @param {boolean} triggerCallback
   */
  activate(tabName, {triggerCallback=false}={}) {

    // Validate the requested tab name
    const group = this._nav.dataset.group;
    const items = this._nav.querySelectorAll("[data-tab]");
    if ( !items.length ) return;
    const valid = Array.from(items).some(i => i.dataset.tab === tabName);
    if ( !valid ) tabName = items[0].dataset.tab;

    // Change active tab
    for ( let i of items ) {
      i.classList.toggle("active", i.dataset.tab === tabName);
    }

    // Change active content
    if ( this._content ) {
      const tabs = this._content.querySelectorAll(".tab[data-tab]");
      for ( let t of tabs ) {
        if ( t.dataset.group && (t.dataset.group !== group) ) continue;
        t.classList.toggle("active", t.dataset.tab === tabName);
      }
    }

    // Store the active tab
    this.active = tabName;

    // Optionally trigger the callback function
    if ( triggerCallback ) this.callback?.(null, this, tabName);
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on the tab navigation entries
   * @param {MouseEvent} event    A left click event
   * @private
   */
  _onClickNav(event) {
    const tab = event.target.closest("[data-tab]");
    if ( !tab ) return;
    event.preventDefault();
    const tabName = tab.dataset.tab;
    if ( tabName !== this.active ) this.activate(tabName, {triggerCallback: true});
  }
}

/**
 * An abstract pattern followed by the different tabs of the sidebar
 * @abstract
 * @interface
 */
class SidebarTab extends Application {
  constructor(...args) {
    super(...args);

    /**
     * A reference to the pop-out variant of this SidebarTab, if one exists
     * @type {SidebarTab}
     * @protected
     */
    this._popout = null;

    /**
     * Denote whether this is the original version of the sidebar tab, or a pop-out variant
     * @type {SidebarTab}
     */
    this._original = null;

    // Adjust options
    if ( this.options.popOut ) this.options.classes.push("sidebar-popout");
    this.options.classes.push(`${this.tabName}-sidebar`);

    // Register the tab as the sidebar singleton
    if ( !this.popOut && ui.sidebar ) ui.sidebar.tabs[this.tabName] = this;
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: null,
      popOut: false,
      width: 300,
      height: "auto",
      classes: ["tab", "sidebar-tab"],
      baseApplication: "SidebarTab"
    });
  }

  /* -------------------------------------------- */

  /** @override */
  get id() {
    return `${this.options.id}${this._original ? "-popout" : ""}`;
  }

  /* -------------------------------------------- */

  /**
   * The base name of this sidebar tab
   * @type {string}
   */
  get tabName() {
    return this.constructor.defaultOptions.id ?? this.id;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    return {
      cssId: this.id,
      cssClass: this.options.classes.join(" "),
      tabName: this.tabName,
      user: game.user
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async _render(force=false, options={}) {
    await super._render(force, options);
    if ( this._popout ) await this._popout._render(force, options);
  }

  /* -------------------------------------------- */

  /** @override */
  async _renderInner(data) {
    let html = await super._renderInner(data);
    if ( ui.sidebar?.activeTab === this.id ) html.addClass("active");
    if ( this.popOut ) html.removeClass("tab");
    return html;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Activate this SidebarTab, switching focus to it
   */
  activate() {
    ui.sidebar.activateTab(this.tabName);
  }

  /* -------------------------------------------- */

  /** @override */
  async close(options) {
    if ( this.popOut ) {
      const base = this._original;
      if ( base ) base._popout = null;
      return super.close(options);
    }
  }

  /* -------------------------------------------- */

  /**
   * Create a second instance of this SidebarTab class which represents a singleton popped-out container
   * @returns {SidebarTab}   The popped out sidebar tab instance
   */
  createPopout() {
    if ( this._popout ) return this._popout;

    // Retain options from the main tab
    const options = {...this.options, popOut: true};
    delete options.id;
    delete options.classes;

    // Create a popout application
    const pop = new this.constructor(options);
    this._popout = pop;
    pop._original = this;
    return pop;
  }

  /* -------------------------------------------- */

  /**
   * Render the SidebarTab as a pop-out container
   */
  renderPopout() {
    const pop = this.createPopout();
    pop.render(true);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle lazy loading for sidebar images to only load them once they become observed
   * @param {HTMLElement[]} entries               The entries which are now observed
   * @param {IntersectionObserver} observer       The intersection observer instance
   */
  _onLazyLoadImage(entries, observer) {
    for ( let e of entries ) {
      if ( !e.isIntersecting ) continue;
      const li = e.target;

      // Background Image
      if ( li.dataset.backgroundImage ) {
        li.style["background-image"] = `url("${li.dataset.backgroundImage}")`;
        delete li.dataset.backgroundImage;
      }

      // Avatar image
      const img = li.querySelector("img");
      if ( img && img.dataset.src ) {
        img.src = img.dataset.src;
        delete img.dataset.src;
      }

      // No longer observe the target
      observer.unobserve(e.target);
    }
  }
}

/**
 * @typedef {Object} DirectoryMixinEntry
 * @property {string} id                The unique id of the entry
 * @property {Folder|string} folder     The folder id or folder object to which this entry belongs
 * @property {string} [img]             An image path to display for the entry
 * @property {string} [sort]            A numeric sort value which orders this entry relative to others
 * @interface
 */

/**
 * Augment an Application instance with functionality that supports rendering as a directory of foldered entries.
 * @param {typeof Application} Base           The base Application class definition
 * @returns {typeof DirectoryApplication}     The decorated DirectoryApplication class definition
 */
function DirectoryApplicationMixin(Base) {
  return class DirectoryApplication extends Base {

    /**
     * The path to the template partial which renders a single Entry within this directory
     * @type {string}
     */
    static entryPartial = "templates/sidebar/partials/entry-partial.html";

    /**
     * The path to the template partial which renders a single Folder within this directory
     * @type {string}
     */
    static folderPartial = "templates/sidebar/folder-partial.html";

    /* -------------------------------------------- */

    /**
     * @inheritdoc
     * @returns {DocumentDirectoryOptions}
     */
    static get defaultOptions() {
      return foundry.utils.mergeObject(super.defaultOptions, {
        renderUpdateKeys: ["name", "sort", "sorting", "folder"],
        height: "auto",
        scrollY: ["ol.directory-list"],
        dragDrop: [{dragSelector: ".directory-item", dropSelector: ".directory-list"}],
        filters: [{inputSelector: 'input[name="search"]', contentSelector: ".directory-list"}],
        contextMenuSelector: ".directory-item.document",
        entryClickSelector: ".entry-name"
      });
    }

    /* -------------------------------------------- */

    /**
     * The type of Entry that is contained in this DirectoryTab.
     * @type {string}
     */
    get entryType() {
      throw new Error("You must implement the entryType getter for this DirectoryTab");
    }

    /* -------------------------------------------- */

    /**
     * The maximum depth of folder nesting which is allowed in this DirectoryTab
     * @returns {number}
     */
    get maxFolderDepth() {
      return this.collection.maxFolderDepth;
    }

    /* -------------------------------------------- */

    /**
     * Can the current User create new Entries in this DirectoryTab?
     * @returns {boolean}
     */
    get canCreateEntry() {
      return game.user.isGM;
    }

    /* -------------------------------------------- */

    /**
     * Can the current User create new Folders in this DirectoryTab?
     * @returns {boolean}
     */
    get canCreateFolder() {
      return this.canCreateEntry;
    }

    /* -------------------------------------------- */

    /** @inheritdoc */
    _onSearchFilter(event, query, rgx, html) {
      const isSearch = !!query;
      let entryIds = new Set();
      const folderIds = new Set();
      const autoExpandFolderIds = new Set();

      // Match entries and folders
      if ( isSearch ) {

        // Include folders and their parents, auto-expanding parent folders
        const includeFolder = (folder, autoExpand = true) => {
          if ( !folder ) return;
          if ( typeof folder === "string" ) folder = this.collection.folders.get(folder);
          if ( !folder ) return;
          const folderId = folder._id;
          if ( folderIds.has(folderId) ) {
            // If this folder is not already auto-expanding, but it should be, add it to the set
            if ( autoExpand && !autoExpandFolderIds.has(folderId) ) autoExpandFolderIds.add(folderId);
            return;
          }
          folderIds.add(folderId);
          if ( autoExpand ) autoExpandFolderIds.add(folderId);
          if ( folder.folder ) includeFolder(folder.folder);
        };

        // First match folders
        this._matchSearchFolders(rgx, includeFolder);

        // Next match entries
        this._matchSearchEntries(rgx, entryIds, folderIds, includeFolder);
      }

      // Toggle each directory item
      for ( let el of html.querySelectorAll(".directory-item") ) {
        if ( el.classList.contains("hidden") ) continue;
        if ( el.classList.contains("folder") ) {
          let match = isSearch && folderIds.has(el.dataset.folderId);
          el.style.display = (!isSearch || match) ? "flex" : "none";
          if ( autoExpandFolderIds.has(el.dataset.folderId) ) {
            if ( isSearch && match ) el.classList.remove("collapsed");
          }
          else el.classList.toggle("collapsed", !game.folders._expanded[el.dataset.uuid]);
        }
        else el.style.display = (!isSearch || entryIds.has(el.dataset.entryId)) ? "flex" : "none";
      }
    }

    /* -------------------------------------------- */

    /**
     * Identify folders in the collection which match a provided search query.
     * This method is factored out to be extended by subclasses, for example to support compendium indices.
     * @param {RegExp} query              The search query
     * @param {Function} includeFolder    A callback function to include the folder of any matched entry
     * @protected
     */
    _matchSearchFolders(query, includeFolder) {
      for ( const folder of this.collection.folders ) {
        if ( query.test(SearchFilter.cleanQuery(folder.name)) ) {
          includeFolder(folder, false);
        }
      }
    }

    /* -------------------------------------------- */

    /**
     * Identify entries in the collection which match a provided search query.
     * This method is factored out to be extended by subclasses, for example to support compendium indices.
     * @param {RegExp} query              The search query
     * @param {Set<string>} entryIds      The set of matched Entry IDs
     * @param {Set<string>} folderIds     The set of matched Folder IDs
     * @param {Function} includeFolder    A callback function to include the folder of any matched entry
     * @protected
     */
    _matchSearchEntries(query, entryIds, folderIds, includeFolder) {
      const nameOnlySearch = (this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME);
      const entries = this.collection.index ?? this.collection.contents;

      // Copy the folderIds to a new set so we can add to the original set without incorrectly adding child entries
      const matchedFolderIds = new Set(folderIds);

      for ( const entry of entries ) {
        const entryId = this._getEntryId(entry);

        // If we matched a folder, add its children entries
        if ( matchedFolderIds.has(entry.folder?._id ?? entry.folder) ) entryIds.add(entryId);

        // Otherwise, if we are searching by name, match the entry name
        else if ( nameOnlySearch && query.test(SearchFilter.cleanQuery(this._getEntryName(entry))) ) {
          entryIds.add(entryId);
          includeFolder(entry.folder);
        }

      }
      if ( nameOnlySearch ) return;

      // Full Text Search
      const matches = this.collection.search({query: query.source, exclude: Array.from(entryIds)});
      for ( const match of matches ) {
        if ( entryIds.has(match._id) ) continue;
        entryIds.add(match._id);
        includeFolder(match.folder);
      }
    }

    /* -------------------------------------------- */

    /**
     * Get the name to search against for a given entry
     * @param {Document|object} entry     The entry to get the name for
     * @returns {string}                  The name of the entry
     * @protected
     */
    _getEntryName(entry) {
      return entry.name;
    }

    /* -------------------------------------------- */

    /**
     * Get the ID for a given entry
     * @param {Document|object} entry     The entry to get the id for
     * @returns {string}                  The id of the entry
     * @protected
     */
    _getEntryId(entry) {
      return entry._id;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async getData(options) {
      const data = await super.getData(options);
      return foundry.utils.mergeObject(data, {
        tree: this.collection.tree,
        entryPartial: this.#getEntryPartial(),
        folderPartial: this.constructor.folderPartial,
        canCreateEntry: this.canCreateEntry,
        canCreateFolder: this.canCreateFolder,
        sortIcon: this.collection.sortingMode === "a" ? "fa-arrow-down-a-z" : "fa-arrow-down-short-wide",
        sortTooltip: this.collection.sortingMode === "a" ? "SIDEBAR.SortModeAlpha" : "SIDEBAR.SortModeManual",
        searchIcon: this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "fa-search" :
          "fa-file-magnifying-glass",
        searchTooltip: this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "SIDEBAR.SearchModeName" :
          "SIDEBAR.SearchModeFull"
      });
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _render(force, options) {
      await loadTemplates([this.#getEntryPartial(), this.constructor.folderPartial]);
      return super._render(force, options);
    }

    /* -------------------------------------------- */

    /**
     * Retrieve the entry partial.
     * @returns {string}
     */
    #getEntryPartial() {
      /**
       * @deprecated since v11
       */
      if ( this.constructor.documentPartial ) {
        foundry.utils.logCompatibilityWarning("Your sidebar application defines the documentPartial static property"
          + " which is deprecated. Please use entryPartial instead.", {since: 11, until: 13});
        return this.constructor.documentPartial;
      }
      return this.constructor.entryPartial;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    activateListeners(html) {
      super.activateListeners(html);
      const directory = html.find(".directory-list");
      const entries = directory.find(".directory-item");

      // Handle folder depth and collapsing
      html.find(`[data-folder-depth="${this.maxFolderDepth}"] .create-folder`).remove();
      html.find(".toggle-sort").click(this.#onToggleSort.bind(this));
      html.find(".toggle-search-mode").click(this.#onToggleSearchMode.bind(this));
      html.find(".collapse-all").click(this.collapseAll.bind(this));

      // Intersection Observer
      const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), { root: directory[0] });
      entries.each((i, li) => observer.observe(li));

      // Entry-level events
      directory.on("click", this.options.entryClickSelector, this._onClickEntryName.bind(this));
      directory.on("click", ".folder-header", this._toggleFolder.bind(this));
      const dh = this._onDragHighlight.bind(this);
      html.find(".folder").on("dragenter", dh).on("dragleave", dh);
      this._contextMenu(html);

      // Allow folder and entry creation
      if ( this.canCreateFolder ) html.find(".create-folder").click(this._onCreateFolder.bind(this));
      if ( this.canCreateEntry ) html.find(".create-entry").click(this._onCreateEntry.bind(this));
    }

    /* -------------------------------------------- */

    /**
     * Swap the sort mode between "a" (Alphabetical) and "m" (Manual by sort property)
     * @param {PointerEvent} event    The originating pointer event
     */
    #onToggleSort(event) {
      event.preventDefault();
      this.collection.toggleSortingMode();
      this.render();
    }

    /* -------------------------------------------- */

    /**
     * Swap the search mode between "name" and "full"
     * @param {PointerEvent} event    The originating pointer event
     */
    #onToggleSearchMode(event) {
      event.preventDefault();
      this.collection.toggleSearchMode();
      this.render();
    }

    /* -------------------------------------------- */

    /**
     * Collapse all subfolders in this directory
     */
    collapseAll() {
      this.element.find("li.folder").addClass("collapsed");
      for ( let f of this.collection.folders ) {
        game.folders._expanded[f.uuid] = false;
      }
      if ( this.popOut ) this.setPosition();
    }

    /* -------------------------------------------- */

    /**
     * Create a new Folder in this SidebarDirectory
     * @param {PointerEvent} event    The originating button click event
     * @protected
     */
    _onCreateFolder(event) {
      event.preventDefault();
      event.stopPropagation();
      const button = event.currentTarget;
      const li = button.closest(".directory-item");
      const data = {folder: li?.dataset?.folderId || null, type: this.entryType};
      const options = {top: button.offsetTop, left: window.innerWidth - 310 - FolderConfig.defaultOptions.width};
      if ( this.collection instanceof CompendiumCollection ) options.pack = this.collection.collection;
      Folder.createDialog(data, options);
    }

    /* -------------------------------------------- */

    /**
     * Handle toggling the collapsed or expanded state of a folder within the directory tab
     * @param {PointerEvent} event    The originating click event
     * @protected
     */
    _toggleFolder(event) {
      let folder = $(event.currentTarget.parentElement);
      let collapsed = folder.hasClass("collapsed");
      const folderUuid = folder[0].dataset.uuid;
      game.folders._expanded[folderUuid] = collapsed;

      // Expand
      if ( collapsed ) folder.removeClass("collapsed");

      // Collapse
      else {
        folder.addClass("collapsed");
        const subs = folder.find(".folder").addClass("collapsed");
        subs.each((i, f) => game.folders._expanded[folderUuid] = false);
      }

      // Resize container
      if ( this.popOut ) this.setPosition();
    }

    /* -------------------------------------------- */

    /**
     * Handle clicking on a Document name in the Sidebar directory
     * @param {PointerEvent} event   The originating click event
     * @protected
     */
    async _onClickEntryName(event) {
      event.preventDefault();
      const element = event.currentTarget;
      const entryId = element.parentElement.dataset.entryId;
      const entry = this.collection.get(entryId);
      entry.sheet.render(true);
    }

    /* -------------------------------------------- */

    /**
     * Handle new Entry creation request
     * @param {PointerEvent} event    The originating button click event
     * @protected
     */
    async _onCreateEntry(event) {
      throw new Error("You must implement the _onCreateEntry method");
    }

    /* -------------------------------------------- */

    /** @override */
    _onDragStart(event) {
      if ( ui.context ) ui.context.close({animate: false});
      const li = event.currentTarget.closest(".directory-item");
      const isFolder = li.classList.contains("folder");
      const dragData = isFolder
        ? this._getFolderDragData(li.dataset.folderId)
        : this._getEntryDragData(li.dataset.entryId);
      if ( !dragData ) return;
      event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
    }

    /* -------------------------------------------- */

    /**
     * Get the data transfer object for a Entry being dragged from this SidebarDirectory
     * @param {string} entryId     The Entry's _id being dragged
     * @returns {Object}
     * @private
     */
    _getEntryDragData(entryId) {
      const entry = this.collection.get(entryId);
      return entry?.toDragData();
    }

    /* -------------------------------------------- */

    /**
     * Get the data transfer object for a Folder being dragged from this SidebarDirectory
     * @param {string} folderId       The Folder _id being dragged
     * @returns {Object}
     * @private
     */
    _getFolderDragData(folderId) {
      const folder = this.collection.folders.get(folderId);
      if ( !folder ) return null;
      return {
        type: "Folder",
        uuid: folder.uuid
      };
    }

    /* -------------------------------------------- */

    /** @override */
    _canDragStart(selector) {
      return true;
    }

    /* -------------------------------------------- */

    /**
     * Highlight folders as drop targets when a drag event enters or exits their area
     * @param {DragEvent} event     The DragEvent which is in progress
     */
    _onDragHighlight(event) {
      const li = event.currentTarget;
      if ( !li.classList.contains("folder") ) return;
      event.stopPropagation();  // Don't bubble to parent folders

      // Remove existing drop targets
      if ( event.type === "dragenter" ) {
        for ( let t of li.closest(".directory-list").querySelectorAll(".droptarget") ) {
          t.classList.remove("droptarget");
        }
      }

      // Remove current drop target
      if ( event.type === "dragleave" ) {
        const el = document.elementFromPoint(event.clientX, event.clientY);
        const parent = el.closest(".folder");
        if ( parent === li ) return;
      }

      // Add new drop target
      li.classList.toggle("droptarget", event.type === "dragenter");
    }

    /* -------------------------------------------- */

    /** @override */
    _onDrop(event) {
      const data = TextEditor.getDragEventData(event);
      if ( !data.type ) return;
      const target = event.target.closest(".directory-item") || null;
      switch ( data.type ) {
        case "Folder":
          return this._handleDroppedFolder(target, data);
        case this.entryType:
          return this._handleDroppedEntry(target, data);
      }
    }

    /* -------------------------------------------- */

    /**
     * Handle Folder data being dropped into the directory.
     * @param {HTMLElement} target    The target element
     * @param {object} data           The data being dropped
     * @protected
     */
    async _handleDroppedFolder(target, data) {

      // Determine the closest Folder
      const closestFolder = target ? target.closest(".folder") : null;
      if ( closestFolder ) closestFolder.classList.remove("droptarget");
      const closestFolderId = closestFolder ? closestFolder.dataset.folderId : null;

      // Obtain the dropped Folder
      let folder = await fromUuid(data.uuid);
      if ( !folder ) return;
      if ( folder?.type !== this.entryType ) {
        const typeLabel = game.i18n.localize(getDocumentClass(this.collection.documentName).metadata.label);
        ui.notifications.warn(game.i18n.format("FOLDER.InvalidDocumentType", {type: typeLabel}));
        return;
      }

      // Sort into another Folder
      const sortData = {sortKey: "sort", sortBefore: true};
      const isRelative = target && target.dataset.folderId;
      if ( isRelative ) {
        const targetFolder = await fromUuid(target.dataset.uuid);

        // Sort relative to a collapsed Folder
        if ( target.classList.contains("collapsed") ) {
          sortData.target = targetFolder;
          sortData.parentId = targetFolder.folder?.id;
          sortData.parentUuid = targetFolder.folder?.uuid;
        }

        // Drop into an expanded Folder
        else {
          sortData.target = null;
          sortData.parentId = targetFolder.id;
          sortData.parentUuid = targetFolder.uuid;
        }
      }

      // Sort relative to existing Folder contents
      else {
        sortData.parentId = closestFolderId;
        sortData.parentUuid = closestFolder?.dataset?.uuid;
        sortData.target = closestFolder && closestFolder.classList.contains("collapsed") ? closestFolder : null;
      }

      if ( sortData.parentId ) {
        const parentFolder = await fromUuid(sortData.parentUuid);
        if ( parentFolder === folder ) return; // Prevent assigning a folder as its own parent.
        if ( parentFolder.ancestors.includes(folder) ) return; // Prevent creating a cycle.
        // Prevent going beyond max depth
        const maxDepth = f => Math.max(f.depth, ...f.children.filter(n => n.folder).map(n => maxDepth(n.folder)));
        if ( (parentFolder.depth + (maxDepth(folder) - folder.depth + 1)) > this.maxFolderDepth ) {
          ui.notifications.error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: this.maxFolderDepth}), {console: false});
          return;
        }
      }

      // Determine siblings
      sortData.siblings = this.collection.folders.filter(f => {
        return (f.folder?.id === sortData.parentId) && (f.type === folder.type) && (f !== folder);
      });

      // Handle dropping of some folder that is foreign to this collection
      if ( this.collection.folders.get(folder.id) !== folder ) {
        const dropped = await this._handleDroppedForeignFolder(folder, closestFolderId, sortData);
        if ( !dropped || !dropped.sortNeeded ) return;
        folder = dropped.folder;
      }

      // Resort the collection
      sortData.updateData = { folder: sortData.parentId };
      return folder.sortRelative(sortData);
    }

    /* -------------------------------------------- */

    /**
     * Handle a new Folder being dropped into the directory.
     * This case is not handled by default, but subclasses may implement custom handling here.
     * @param {Folder} folder               The Folder being dropped
     * @param {string} closestFolderId      The closest Folder _id to the drop target
     * @param {object} sortData             The sort data for the Folder
     * @param {string} sortData.sortKey     The sort key to use for sorting
     * @param {boolean} sortData.sortBefore Sort before the target?
     * @returns {Promise<{folder: Folder, sortNeeded: boolean}|null>} The handled folder creation, or null
     * @protected
     */
    async _handleDroppedForeignFolder(folder, closestFolderId, sortData) {
      return null;
    }

    /* -------------------------------------------- */

    /**
     * Handle Entry data being dropped into the directory.
     * @param {HTMLElement} target    The target element
     * @param {object} data           The data being dropped
     * @protected
     */
    async _handleDroppedEntry(target, data) {
      // Determine the closest Folder
      const closestFolder = target ? target.closest(".folder") : null;
      if ( closestFolder ) closestFolder.classList.remove("droptarget");
      let folder = closestFolder ? await fromUuid(closestFolder.dataset.uuid) : null;

      let entry = await this._getDroppedEntryFromData(data);
      if ( !entry ) return;

      // Sort relative to another Document
      const collection = this.collection.index ?? this.collection;
      const sortData = {sortKey: "sort"};
      const isRelative = target && target.dataset.entryId;
      if ( isRelative ) {
        if ( entry.id === target.dataset.entryId ) return; // Don't drop on yourself
        const targetDocument = collection.get(target.dataset.entryId);
        sortData.target = targetDocument;
        folder = targetDocument?.folder;
      }

      // Sort within to the closest Folder
      else sortData.target = null;

      // Determine siblings
      if ( folder instanceof foundry.abstract.Document ) folder = folder.id;
      sortData.siblings = collection.filter(d => !this._entryIsSelf(d, entry) && this._entryBelongsToFolder(d, folder));

      if ( !this._entryAlreadyExists(entry) ) {
        // Try to predetermine the sort order
        const sorted = SortingHelpers.performIntegerSort(entry, sortData);
        if ( sorted.length === 1 ) entry = entry.clone({sort: sorted[0].update[sortData.sortKey]}, {keepId: true});
        entry = await this._createDroppedEntry(entry, folder);

        // No need to resort other documents if the document was created with a specific sort order
        if ( sorted.length === 1 ) return;
      }

      // Resort the collection
      sortData.updateData = {folder: folder || null};
      return this._sortRelative(entry, sortData);
    }

    /* -------------------------------------------- */

    /**
     * Determine if an Entry is being compared to itself
     * @param {DirectoryMixinEntry} entry          The Entry
     * @param {DirectoryMixinEntry} otherEntry     The other Entry
     * @returns {boolean}                          Is the Entry being compared to itself?
     * @protected
     */
    _entryIsSelf(entry, otherEntry) {
      return entry._id === otherEntry._id;
    }

    /* -------------------------------------------- */

    /**
     * Determine whether an Entry belongs to the target folder
     * @param {DirectoryMixinEntry} entry   The Entry
     * @param {Folder} folder               The target folder
     * @returns {boolean}                   Is the Entry a sibling?
     * @protected
     */
    _entryBelongsToFolder(entry, folder) {
      if ( !entry.folder && !folder ) return true;
      if ( entry.folder instanceof foundry.abstract.Document ) return entry.folder.id === folder;
      return entry.folder === folder;
    }

    /* -------------------------------------------- */

    /**
     * Check if an Entry is already present in the Collection
     * @param {DirectoryMixinEntry} entry     The Entry being dropped
     * @returns {boolean}                     Is the Entry already present?
     * @private
     */
    _entryAlreadyExists(entry) {
      return this.collection.get(entry.id) === entry;
    }

    /* -------------------------------------------- */

    /**
     * Get the dropped Entry from the drop data
     * @param {object} data                      The data being dropped
     * @returns {Promise<DirectoryMixinEntry>}   The dropped Entry
     * @protected
     */
    async _getDroppedEntryFromData(data) {
      throw new Error("The _getDroppedEntryFromData method must be implemented");
    }

    /* -------------------------------------------- */

    /**
     * Create a dropped Entry in this Collection
     * @param {DirectoryMixinEntry} entry       The Entry being dropped
     * @param {string} [folderId]               The ID of the Folder to which the Entry should be added
     * @returns {Promise<DirectoryMixinEntry>}  The created Entry
     * @protected
     */
    async _createDroppedEntry(entry, folderId) {
      throw new Error("The _createDroppedEntry method must be implemented");
    }

    /* -------------------------------------------- */

    /**
     * Sort a relative entry within a collection
     * @param {DirectoryMixinEntry} entry   The entry to sort
     * @param {object} sortData             The sort data
     * @param {string} sortData.sortKey     The sort key to use for sorting
     * @param {boolean} sortData.sortBefore Sort before the target?
     * @param {object} sortData.updateData  Additional data to update on the entry
     * @returns {Promise<object>}           The sorted entry
     */
    async _sortRelative(entry, sortData) {
      throw new Error("The _sortRelative method must be implemented");
    }

    /* -------------------------------------------- */

    /** @inheritdoc */
    _contextMenu(html) {
      /**
       * A hook event that fires when the context menu for folders in this DocumentDirectory is constructed.
       * Substitute the class name in the hook event, for example "getActorDirectoryFolderContext".
       * @function getSidebarTabFolderContext
       * @memberof hookEvents
       * @param {DirectoryApplication} application The Application instance that the context menu is constructed in
       * @param {ContextMenuEntry[]} entryOptions The context menu entries
       */
      ContextMenu.create(this, html, ".folder .folder-header", this._getFolderContextOptions(), {
        hookName: "FolderContext"
      });
      ContextMenu.create(this, html, this.options.contextMenuSelector, this._getEntryContextOptions());
    }

    /* -------------------------------------------- */

    /**
     * Get the set of ContextMenu options which should be used for Folders in a SidebarDirectory
     * @returns {object[]}   The Array of context options passed to the ContextMenu instance
     * @protected
     */
    _getFolderContextOptions() {
      return [
        {
          name: "FOLDER.Edit",
          icon: '<i class="fas fa-edit"></i>',
          condition: game.user.isGM,
          callback: async header => {
            const li = header.closest(".directory-item")[0];
            const folder = await fromUuid(li.dataset.uuid);
            const r = li.getBoundingClientRect();
            const options = {top: r.top, left: r.left - FolderConfig.defaultOptions.width - 10};
            new FolderConfig(folder, options).render(true);
          }
        },
        {
          name: "FOLDER.CreateTable",
          icon: `<i class="${CONFIG.RollTable.sidebarIcon}"></i>`,
          condition: header => {
            const li = header.closest(".directory-item")[0];
            const folder = fromUuidSync(li.dataset.uuid);
            return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
          },
          callback: async header => {
            const li = header.closest(".directory-item")[0];
            const folder = await fromUuid(li.dataset.uuid);
            return Dialog.confirm({
              title: `${game.i18n.localize("FOLDER.CreateTable")}: ${folder.name}`,
              content: game.i18n.localize("FOLDER.CreateTableConfirm"),
              yes: () => RollTable.fromFolder(folder),
              options: {
                top: Math.min(li.offsetTop, window.innerHeight - 350),
                left: window.innerWidth - 680,
                width: 360
              }
            });
          }
        },
        {
          name: "FOLDER.Remove",
          icon: '<i class="fas fa-trash"></i>',
          condition: game.user.isGM,
          callback: async header => {
            const li = header.closest(".directory-item")[0];
            const folder = await fromUuid(li.dataset.uuid);
            return Dialog.confirm({
              title: `${game.i18n.localize("FOLDER.Remove")} ${folder.name}`,
              content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("FOLDER.RemoveWarning")}</p>`,
              yes: () => folder.delete({deleteSubfolders: false, deleteContents: false}),
              options: {
                top: Math.min(li.offsetTop, window.innerHeight - 350),
                left: window.innerWidth - 720,
                width: 400
              }
            });
          }
        },
        {
          name: "FOLDER.Delete",
          icon: '<i class="fas fa-dumpster"></i>',
          condition: game.user.isGM && (this.entryType !== "Compendium"),
          callback: async header => {
            const li = header.closest(".directory-item")[0];
            const folder = await fromUuid(li.dataset.uuid);
            return Dialog.confirm({
              title: `${game.i18n.localize("FOLDER.Delete")} ${folder.name}`,
              content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("FOLDER.DeleteWarning")}</p>`,
              yes: () => folder.delete({deleteSubfolders: true, deleteContents: true}),
              options: {
                top: Math.min(li.offsetTop, window.innerHeight - 350),
                left: window.innerWidth - 720,
                width: 400
              }
            });
          }
        }
      ];
    }

    /* -------------------------------------------- */

    /**
     * Get the set of ContextMenu options which should be used for Entries in a SidebarDirectory
     * @returns {object[]}   The Array of context options passed to the ContextMenu instance
     * @protected
     */
    _getEntryContextOptions() {
      return [
        {
          name: "FOLDER.Clear",
          icon: '<i class="fas fa-folder"></i>',
          condition: header => {
            const li = header.closest(".directory-item");
            const entry = this.collection.get(li.data("entryId"));
            return game.user.isGM && !!entry.folder;
          },
          callback: header => {
            const li = header.closest(".directory-item");
            const entry = this.collection.get(li.data("entryId"));
            entry.update({folder: null});
          }
        },
        {
          name: "SIDEBAR.Delete",
          icon: '<i class="fas fa-trash"></i>',
          condition: () => game.user.isGM,
          callback: header => {
            const li = header.closest(".directory-item");
            const entry = this.collection.get(li.data("entryId"));
            if ( !entry ) return;
            return entry.deleteDialog({
              top: Math.min(li[0].offsetTop, window.innerHeight - 350),
              left: window.innerWidth - 720
            });
          }
        },
        {
          name: "SIDEBAR.Duplicate",
          icon: '<i class="far fa-copy"></i>',
          condition: () => game.user.isGM || this.collection.documentClass.canUserCreate(game.user),
          callback: header => {
            const li = header.closest(".directory-item");
            const original = this.collection.get(li.data("entryId"));
            return original.clone({name: `${original._source.name} (Copy)`}, {save: true, addSource: true});
          }
        }
      ];
    }
  };
}

/**
 * @typedef {ApplicationOptions} DocumentDirectoryOptions
 * @property {string[]} [renderUpdateKeys]   A list of data property keys that will trigger a rerender of the tab if
 *                                           they are updated on a Document that this tab is responsible for.
 * @property {string} [contextMenuSelector]  The CSS selector that activates the context menu for displayed Documents.
 * @property {string} [entryClickSelector]   The CSS selector for the clickable area of an entry in the tab.
 */

/**
 * A shared pattern for the sidebar directory which Actors, Items, and Scenes all use
 * @extends {SidebarTab}
 * @abstract
 * @interface
 *
 * @param {DocumentDirectoryOptions} [options]  Application configuration options.
 */
class DocumentDirectory extends DirectoryApplicationMixin(SidebarTab) {
  constructor(options={}) {
    super(options);

    /**
     * References to the set of Documents which are displayed in the Sidebar
     * @type {ClientDocument[]}
     */
    this.documents = null;

    /**
     * Reference the set of Folders which exist in this Sidebar
     * @type {Folder[]}
     */
    this.folders = null;

    // If a collection was provided, use it instead of the default
    this.#collection = options.collection ?? this.constructor.collection;

    // Initialize sidebar content
    this.initialize();

    // Record the directory as an application of the collection if it is not a popout
    if ( !this.options.popOut ) this.collection.apps.push(this);
  }

  /* -------------------------------------------- */

  /**
   * A reference to the named Document type that this Sidebar Directory instance displays
   * @type {string}
   */
  static documentName = "Document";

  /** @override */
  static entryPartial = "templates/sidebar/partials/document-partial.html";

  /** @override */
  get entryType() {
    return this.constructor.documentName;
  }

  /* -------------------------------------------- */

  /**
   * @override
   * @returns {DocumentDirectoryOptions}
   */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "templates/sidebar/document-directory.html",
      renderUpdateKeys: ["name", "img", "thumb", "ownership", "sort", "sorting", "folder"]
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    const cls = getDocumentClass(this.constructor.documentName);
    return `${game.i18n.localize(cls.metadata.labelPlural)} Directory`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get id() {
    const cls = getDocumentClass(this.constructor.documentName);
    const pack = cls.metadata.collection;
    return `${pack}${this._original ? "-popout" : ""}`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get tabName() {
    const cls = getDocumentClass(this.constructor.documentName);
    return cls.metadata.collection;
  }

  /* -------------------------------------------- */

  /**
   * The WorldCollection instance which this Sidebar Directory displays.
   * @type {WorldCollection}
   */
  static get collection() {
    return game.collections.get(this.documentName);
  }

  /* -------------------------------------------- */

  /**
   * The collection of Documents which are displayed in this Sidebar Directory
   * @type {DocumentCollection}
   */
  get collection() {
    return this.#collection;
  }

  /* -------------------------------------------- */

  /**
   * A per-instance reference to a collection of documents which are displayed in this Sidebar Directory. If set, supersedes the World Collection
   * @private
   */
  #collection;

  /* -------------------------------------------- */
  /*  Initialization Helpers                      */

  /* -------------------------------------------- */

  /**
   * Initialize the content of the directory by categorizing folders and documents into a hierarchical tree structure.
   */
  initialize() {

    // Assign Folders
    this.folders = this.collection.folders.contents;

    // Assign Documents
    this.documents = this.collection.filter(e => e.visible);

    // Build Tree
    this.collection.initializeTree();
  }


  /* -------------------------------------------- */
  /*  Application Rendering
  /* -------------------------------------------- */

  /** @inheritdoc */
  async _render(force, context={}) {

    // Only re-render the sidebar directory for certain types of updates
    const {renderContext, renderData} = context;
    if ( (renderContext === `update${this.entryType}`) && !renderData?.some(d => {
      return this.options.renderUpdateKeys.some(k => foundry.utils.hasProperty(d, k));
    }) ) return;

    // Re-build the tree and render
    this.initialize();
    return super._render(force, context);
  }

  /* -------------------------------------------- */

  /** @override */
  get canCreateEntry() {
    const cls = getDocumentClass(this.constructor.documentName);
    return cls.canUserCreate(game.user);
  }

  /* -------------------------------------------- */

  /** @override */
  get canCreateFolder() {
    return this.canCreateEntry;
  }

  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    const context = await super.getData(options);
    const cfg = CONFIG[this.collection.documentName];
    const cls = cfg.documentClass;
    return foundry.utils.mergeObject(context, {
      documentCls: cls.documentName.toLowerCase(),
      tabName: cls.metadata.collection,
      sidebarIcon: cfg.sidebarIcon,
      folderIcon: CONFIG.Folder.sidebarIcon,
      label: game.i18n.localize(cls.metadata.label),
      labelPlural: game.i18n.localize(cls.metadata.labelPlural),
      unavailable: game.user.isGM ? cfg.collection?.instance?.invalidDocumentIds?.size : 0
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".show-issues").on("click", () => new SupportDetails().render(true, {tab: "documents"}));
  }

  /* -------------------------------------------- */

  /** @override */
  async _onClickEntryName(event) {
    event.preventDefault();
    const element = event.currentTarget;
    const documentId = element.parentElement.dataset.documentId;
    const document = this.collection.get(documentId) ?? await this.collection.getDocument(documentId);
    document.sheet.render(true);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onCreateEntry(event, { _skipDeprecated=false }={}) {
    /**
     * @deprecated since v11
     */
    if ( (this._onCreateDocument !== DocumentDirectory.prototype._onCreateDocument) && !_skipDeprecated ) {
      foundry.utils.logCompatibilityWarning("DocumentDirectory#_onCreateDocument is deprecated. "
        + "Please use DocumentDirectory#_onCreateEntry instead.", {since: 11, until: 13});
      return this._onCreateDocument(event);
    }

    event.preventDefault();
    event.stopPropagation();
    const button = event.currentTarget;
    const li = button.closest(".directory-item");
    const data = {folder: li?.dataset?.folderId};
    const options = {width: 320, left: window.innerWidth - 630, top: button.offsetTop };
    if ( this.collection instanceof CompendiumCollection ) options.pack = this.collection.collection;
    const cls = getDocumentClass(this.collection.documentName);
    return cls.createDialog(data, options);
  }

  /* -------------------------------------------- */

  /** @override */
  _onDrop(event) {
    const data = TextEditor.getDragEventData(event);
    if ( !data.type ) return;
    const target = event.target.closest(".directory-item") || null;

    // Call the drop handler
    switch ( data.type ) {
      case "Folder":
        return this._handleDroppedFolder(target, data);
      case this.collection.documentName:
        return this._handleDroppedEntry(target, data);
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _handleDroppedEntry(target, data, { _skipDeprecated=false }={}) {
    /**
     * @deprecated since v11
     */
    if ( (this._handleDroppedDocument !== DocumentDirectory.prototype._handleDroppedDocument) && !_skipDeprecated ) {
      foundry.utils.logCompatibilityWarning("DocumentDirectory#_handleDroppedDocument is deprecated. "
        + "Please use DocumentDirectory#_handleDroppedEntry instead.", {since: 11, until: 13});
      return this._handleDroppedDocument(target, data);
    }

    return super._handleDroppedEntry(target, data);
  }

  /* -------------------------------------------- */

  /** @override */
  async _getDroppedEntryFromData(data) {
    const cls = this.collection.documentClass;
    return cls.fromDropData(data);
  }

  /* -------------------------------------------- */

  /** @override */
  async _sortRelative(entry, sortData) {
    return entry.sortRelative(sortData);
  }

  /* -------------------------------------------- */

  /** @override */
  async _createDroppedEntry(document, folderId) {
    const data = document.toObject();
    data.folder = folderId || null;
    return document.constructor.create(data, {fromCompendium: !!document.compendium });
  }

  /* -------------------------------------------- */

  /** @override */
  async _handleDroppedForeignFolder(folder, closestFolderId, sortData) {
    const createdFolders = await this._createDroppedFolderContent(folder, this.collection.folders.get(closestFolderId));
    if ( createdFolders.length ) folder = createdFolders[0];
    return {
      sortNeeded: true,
      folder: folder
    };
  }

  /* -------------------------------------------- */

  /**
   * Create a dropped Folder and its children in this Collection, if they do not already exist
   * @param {Folder} folder                  The Folder being dropped
   * @param {Folder} targetFolder            The Folder to which the Folder should be added
   * @returns {Promise<Array<Folder>>}       The created Folders
   * @protected
   */
  async _createDroppedFolderContent(folder, targetFolder) {

    const {foldersToCreate, documentsToCreate} = await this._organizeDroppedFoldersAndDocuments(folder, targetFolder);

    // Create Folders
    let createdFolders;
    try {
      createdFolders = await Folder.createDocuments(foldersToCreate, {
        pack: this.collection.collection,
        keepId: true
      });
    }
    catch (err) {
      ui.notifications.error(err.message);
      throw err;
    }

    // Create Documents
    await this._createDroppedFolderDocuments(folder, documentsToCreate);

    return createdFolders;
  }

  /* -------------------------------------------- */

  /**
   * Organize a dropped Folder and its children into a list of folders to create and documents to create
   * @param {Folder} folder                  The Folder being dropped
   * @param {Folder} targetFolder            The Folder to which the Folder should be added
   * @returns {Promise<{foldersToCreate: Array<Folder>, documentsToCreate: Array<Document>}>}
   * @private
   */
  async _organizeDroppedFoldersAndDocuments(folder, targetFolder) {
    let foldersToCreate = [];
    let documentsToCreate = [];
    let exceededMaxDepth = false;
    const addFolder = (folder, currentDepth) => {
      if ( !folder ) return;

      // If the Folder does not already exist, add it to the list of folders to create
      if ( this.collection.folders.get(folder.id) !== folder ) {
        const createData = folder.toObject();
        if ( targetFolder ) {
          createData.folder = targetFolder.id;
          targetFolder = undefined;
        }
        if ( currentDepth > this.maxFolderDepth ) {
          exceededMaxDepth = true;
          return;
        }
        createData.pack = this.collection.collection;
        foldersToCreate.push(createData);
      }

      // If the Folder has documents, check those as well
      if ( folder.contents?.length ) {
        for ( const document of folder.contents ) {
          const createData = document.toObject ? document.toObject() : foundry.utils.deepClone(document);
          documentsToCreate.push(createData);
        }
      }

      // Recursively check child folders
      for ( const child of folder.children ) {
        addFolder(child.folder, currentDepth + 1);
      }
    };

    const currentDepth = (targetFolder?.ancestors.length ?? 0) + 1;
    addFolder(folder, currentDepth);
    if ( exceededMaxDepth ) {
      ui.notifications.error(game.i18n.format("FOLDER.ExceededMaxDepth", {depth: this.maxFolderDepth}), {console: false});
      foldersToCreate.length = documentsToCreate.length = 0;
    }
    return {foldersToCreate, documentsToCreate};
  }

  /* -------------------------------------------- */

  /**
   * Create a list of documents in a dropped Folder
   * @param {Folder} folder                  The Folder being dropped
   * @param {Array<Document>} documentsToCreate   The documents to create
   * @returns {Promise<void>}
   * @protected
   */
  async _createDroppedFolderDocuments(folder, documentsToCreate) {
    if ( folder.pack ) {
      const pack = game.packs.get(folder.pack);
      if ( pack ) {
        const ids = documentsToCreate.map(d => d._id);
        documentsToCreate = await pack.getDocuments({_id__in: ids});
      }
    }

    try {
      await this.collection.documentClass.createDocuments(documentsToCreate, {
        pack: this.collection.collection,
        keepId: true
      });
    }
    catch (err) {
      ui.notifications.error(err.message);
      throw err;
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the set of ContextMenu options which should be used for Folders in a SidebarDirectory
   * @returns {object[]}   The Array of context options passed to the ContextMenu instance
   * @protected
   */
  _getFolderContextOptions() {
    const options = super._getFolderContextOptions();
    return options.concat([
      {
        name: "OWNERSHIP.Configure",
        icon: '<i class="fas fa-lock"></i>',
        condition: () => game.user.isGM,
        callback: async header => {
          const li = header.closest(".directory-item")[0];
          const folder = await fromUuid(li.dataset.uuid);
          new DocumentOwnershipConfig(folder, {
            top: Math.min(li.offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720
          }).render(true);
        }
      },
      {
        name: "FOLDER.Export",
        icon: '<i class="fas fa-atlas"></i>',
        condition: header => {
          const folder = fromUuidSync(header.parent().data("uuid"));
          return CONST.COMPENDIUM_DOCUMENT_TYPES.includes(folder.type);
        },
        callback: async header => {
          const li = header.closest(".directory-item")[0];
          const folder = await fromUuid(li.dataset.uuid);
          return folder.exportDialog(null, {
            top: Math.min(li.offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720,
            width: 400
          });
        }
      }
    ]);
  }

  /* -------------------------------------------- */

  /**
   * Get the set of ContextMenu options which should be used for Documents in a SidebarDirectory
   * @returns {object[]}   The Array of context options passed to the ContextMenu instance
   * @protected
   */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    return [
      {
        name: "OWNERSHIP.Configure",
        icon: '<i class="fas fa-lock"></i>',
        condition: () => game.user.isGM,
        callback: header => {
          const li = header.closest(".directory-item");
          const document = this.collection.get(li.data("documentId"));
          new DocumentOwnershipConfig(document, {
            top: Math.min(li[0].offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720
          }).render(true);
        }
      },
      {
        name: "SIDEBAR.Export",
        icon: '<i class="fas fa-file-export"></i>',
        condition: header => {
          const li = header.closest(".directory-item");
          const document = this.collection.get(li.data("documentId"));
          return document.isOwner;
        },
        callback: header => {
          const li = header.closest(".directory-item");
          const document = this.collection.get(li.data("documentId"));
          return document.exportToJSON();
        }
      },
      {
        name: "SIDEBAR.Import",
        icon: '<i class="fas fa-file-import"></i>',
        condition: header => {
          const li = header.closest(".directory-item");
          const document = this.collection.get(li.data("documentId"));
          return document.isOwner;
        },
        callback: header => {
          const li = header.closest(".directory-item");
          const document = this.collection.get(li.data("documentId"));
          return document.importFromJSONDialog();
        }
      }
    ].concat(options);
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  async _onCreateDocument(event) {
    foundry.utils.logCompatibilityWarning("DocumentDirectory#_onCreateDocument is deprecated. "
      + "Please use DocumentDirectory#_onCreateEntry instead.", {since: 11, until: 13});
    return this._onCreateEntry(event, { _skipDeprecated: true });
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  async _handleDroppedDocument(target, data) {
    foundry.utils.logCompatibilityWarning("DocumentDirectory#_handleDroppedDocument is deprecated. "
      + "Please use DocumentDirectory#_handleDroppedEntry instead.", {since: 11, until: 13});
    return this._handleDroppedEntry(target, data, { _skipDeprecated: true });
  }
}

/**
 * @deprecated since v11
 */
Object.defineProperty(globalThis, "SidebarDirectory", {
  get() {
    foundry.utils.logCompatibilityWarning("SidebarDirectory has been deprecated. Please use DocumentDirectory instead.",
      {since: 11, until: 13});
    return DocumentDirectory;
  }
});

/**
 * An application for configuring data across all installed and active packages.
 */
class PackageConfiguration extends FormApplication {

  static get categoryOrder() {
    return ["all", "core", "system", "module", "unmapped"];
  }

  /**
   * The name of the currently active tab.
   * @type {string}
   */
  get activeCategory() {
    return this._tabs[0].active;
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["package-configuration"],
      template: "templates/sidebar/apps/package-configuration.html",
      categoryTemplate: undefined,
      width: 780,
      height: 680,
      resizable: true,
      scrollY: [".filters", ".categories"],
      tabs: [{navSelector: ".tabs", contentSelector: "form .scrollable", initial: "all"}],
      filters: [{inputSelector: 'input[name="filter"]', contentSelector: ".categories"}],
      submitButton: false
    });
  }

  /* -------------------------------------------- */

  /** @override */
  getData(options={}) {
    const data = this._prepareCategoryData();
    data.categoryTemplate = this.options.categoryTemplate;
    data.submitButton = this.options.submitButton;
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the structure of category data which is rendered in this configuration form.
   * @abstract
   * @protected
   */
  _prepareCategoryData() {
    return {categories: [], total: 0};
  }

  /* -------------------------------------------- */

  /**
   * Classify what Category an Action belongs to
   * @param {string} namespace                The entry to classify
   * @returns {{id: string, title: string}}   The category the entry belongs to
   * @protected
   */
  _categorizeEntry(namespace) {
    if ( namespace === "core" ) return {
      id: "core",
      title: game.i18n.localize("PACKAGECONFIG.Core")
    };
    else if ( namespace === game.system.id ) return {
      id: "system",
      title: game.system.title
    };
    else {
      const module = game.modules.get(namespace);
      if ( module ) return {
        id: module.id,
        title: module.title
      };
      return {
        id: "unmapped",
        title: game.i18n.localize("PACKAGECONFIG.Unmapped")
      };
    }
  }

  /* -------------------------------------------- */

  /**
   * Reusable logic for how categories are sorted in relation to each other.
   * @param {object} a
   * @param {object} b
   * @protected
   */
  _sortCategories(a, b) {
    const categories = this.constructor.categoryOrder;
    let ia = categories.indexOf(a.id);
    if ( ia === -1 ) ia = categories.length - 2; // Modules second from last
    let ib = this.constructor.categoryOrder.indexOf(b.id);
    if ( ib === -1 ) ib = categories.length - 2; // Modules second from last
    return (ia - ib) || a.title.localeCompare(b.title, game.i18n.lang);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _render(force, {activeCategory, ...options}={}) {
    await loadTemplates([this.options.categoryTemplate]);
    await super._render(force, options);
    if ( activeCategory ) this._tabs[0].activate(activeCategory);
    const activeTab = this._tabs[0]?.active;
    if ( activeTab ) this.element[0].querySelector(`.tabs [data-tab="${activeTab}"]`)?.scrollIntoView();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    if ( this.activeCategory === "all" ) {
      this._tabs[0]._content.querySelectorAll(".tab").forEach(tab => tab.classList.add("active"));
    }
    html.find("button.reset-all").click(this._onResetDefaults.bind(this));
    html.find("input[name=filter]").focus();
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeTab(event, tabs, active) {
    if ( active === "all" ) {
      tabs._content.querySelectorAll(".tab").forEach(tab => tab.classList.add("active"));
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onSearchFilter(event, query, rgx, html) {
    const visibleCategories = new Set();

    // Hide entries
    for ( const entry of html.querySelectorAll(".form-group") ) {
      if ( !query ) {
        entry.classList.remove("hidden");
        continue;
      }
      const label = entry.querySelector("label")?.textContent;
      const notes = entry.querySelector(".notes")?.textContent;
      const match = (label && rgx.test(SearchFilter.cleanQuery(label)))
        || (notes && rgx.test(SearchFilter.cleanQuery(notes)));
      entry.classList.toggle("hidden", !match);
      if ( match ) visibleCategories.add(entry.parentElement.dataset.category);
    }

    // Hide categories which have no visible children
    for ( const category of html.querySelectorAll(".category") ) {
      category.classList.toggle("hidden", query && !visibleCategories.has(category.dataset.category));
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle button click to reset default settings
   * @param {Event} event   The initial button click event
   * @abstract
   * @protected
   */
  _onResetDefaults(event) {}
}

/**
 * Render the Sidebar container, and after rendering insert Sidebar tabs.
 */
class Sidebar extends Application {

  /**
   * Singleton application instances for each sidebar tab
   * @type {Record<string, SidebarTab>}
   */
  tabs = {};

  /**
   * Track whether the sidebar container is currently collapsed
   * @type {boolean}
   */
  _collapsed = false;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "sidebar",
      template: "templates/sidebar/sidebar.html",
      popOut: false,
      width: 300,
      tabs: [{navSelector: ".tabs", contentSelector: "#sidebar", initial: "chat"}]
    });
  }

  /* -------------------------------------------- */

  /**
   * Return the name of the active Sidebar tab
   * @type {string}
   */
  get activeTab() {
    return this._tabs[0].active;
  }


  /* -------------------------------------------- */

  /**
   * Singleton application instances for each popout tab
   * @type {Record<string, SidebarTab>}
   */
  get popouts() {
    const popouts = {};
    for ( let [name, app] of Object.entries(this.tabs) ) {
      if ( app._popout ) popouts[name] = app._popout;
    }
    return popouts;
  }

  /* -------------------------------------------- */
  /*  Rendering
  /* -------------------------------------------- */

  /** @override */
  getData(options={}) {
    const isGM = game.user.isGM;

    // Configure tabs
    const tabs = {
      chat: {
        tooltip: ChatMessage.metadata.labelPlural,
        icon: CONFIG.ChatMessage.sidebarIcon,
        notification: "<i id=\"chat-notification\" class=\"notification-pip fas fa-exclamation-circle\"></i>"
      },
      combat: {
        tooltip: Combat.metadata.labelPlural,
        icon: CONFIG.Combat.sidebarIcon
      },
      scenes: {
        tooltip: Scene.metadata.labelPlural,
        icon: CONFIG.Scene.sidebarIcon
      },
      actors: {
        tooltip: Actor.metadata.labelPlural,
        icon: CONFIG.Actor.sidebarIcon
      },
      items: {
        tooltip: Item.metadata.labelPlural,
        icon: CONFIG.Item.sidebarIcon
      },
      journal: {
        tooltip: "SIDEBAR.TabJournal",
        icon: CONFIG.JournalEntry.sidebarIcon
      },
      tables: {
        tooltip: RollTable.metadata.labelPlural,
        icon: CONFIG.RollTable.sidebarIcon
      },
      cards: {
        tooltip: Cards.metadata.labelPlural,
        icon: CONFIG.Cards.sidebarIcon
      },
      playlists: {
        tooltip: Playlist.metadata.labelPlural,
        icon: CONFIG.Playlist.sidebarIcon
      },
      compendium: {
        tooltip: "SIDEBAR.TabCompendium",
        icon: "fas fa-atlas"
      },
      settings: {
        tooltip: "SIDEBAR.TabSettings",
        icon: "fas fa-cogs"
      }
    };
    if ( !isGM ) delete tabs.scenes;

    // Display core or system update notification?
    if ( isGM && (game.data.coreUpdate.hasUpdate || game.data.systemUpdate.hasUpdate) ) {
      tabs.settings.notification = `<i class="notification-pip fas fa-exclamation-circle"></i>`;
    }
    return {tabs};
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _render(force, options) {

    // Render the Sidebar container only once
    if ( !this.rendered ) await super._render(force, options);

    // Render sidebar Applications
    const renders = [];
    for ( let [name, app] of Object.entries(this.tabs) ) {
      renders.push(app._render(true).catch(err => {
        Hooks.onError("Sidebar#_render", err, {
          msg: `Failed to render Sidebar tab ${name}`,
          log: "error",
          name
        });
      }));
    }

    Promise.all(renders).then(() => this.activateTab(this.activeTab));
  }

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /**
   * Expand the Sidebar container from a collapsed state.
   * Take no action if the sidebar is already expanded.
   */
  expand() {
    if ( !this._collapsed ) return;
    const sidebar = this.element;
    const tab = sidebar.find(".sidebar-tab.active");
    const tabs = sidebar.find("#sidebar-tabs");
    const icon = tabs.find("a.collapse i");

    // Animate the sidebar expansion
    tab.hide();
    sidebar.animate({width: this.options.width, height: this.position.height}, 150, () => {
      sidebar.css({width: "", height: ""}); // Revert to default styling
      sidebar.removeClass("collapsed");
      tabs[0].dataset.tooltipDirection = TooltipManager.TOOLTIP_DIRECTIONS.DOWN;
      tab.fadeIn(250, () => {
        tab.css({
          display: "",
          height: ""
        });
      });
      icon.removeClass("fa-caret-left").addClass("fa-caret-right");
      this._collapsed = false;
      Hooks.callAll("collapseSidebar", this, this._collapsed);
    });
  }

  /* -------------------------------------------- */

  /**
   * Collapse the sidebar to a minimized state.
   * Take no action if the sidebar is already collapsed.
   */
  collapse() {
    if ( this._collapsed ) return;
    const sidebar = this.element;
    const tab = sidebar.find(".sidebar-tab.active");
    const tabs = sidebar.find("#sidebar-tabs");
    const icon = tabs.find("a.collapse i");

    // Animate the sidebar collapse
    tab.fadeOut(250, () => {
      sidebar.animate({width: 32, height: (32 + 4) * (Object.values(this.tabs).length + 1)}, 150, () => {
        sidebar.css("height", ""); // Revert to default styling
        sidebar.addClass("collapsed");
        tabs[0].dataset.tooltipDirection = TooltipManager.TOOLTIP_DIRECTIONS.LEFT;
        tab.css("display", "");
        icon.removeClass("fa-caret-right").addClass("fa-caret-left");
        this._collapsed = true;
        Hooks.callAll("collapseSidebar", this, this._collapsed);
      });
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers
  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);

    // Right click pop-out
    const nav = this._tabs[0]._nav;
    nav.addEventListener("contextmenu", this._onRightClickTab.bind(this));

    // Toggle Collapse
    const collapse = nav.querySelector(".collapse");
    collapse.addEventListener("click", this._onToggleCollapse.bind(this));

    // Left click a tab
    const tabs = nav.querySelectorAll(".item");
    tabs.forEach(tab => tab.addEventListener("click", this._onLeftClickTab.bind(this)));
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeTab(event, tabs, active) {
    const app = ui[active];
    Hooks.callAll("changeSidebarTab", app);
  }

  /* -------------------------------------------- */

  /**
   * Handle the special case of left-clicking a tab when the sidebar is collapsed.
   * @param {MouseEvent} event  The originating click event
   * @private
   */
  _onLeftClickTab(event) {
    const app = ui[event.currentTarget.dataset.tab];
    if ( app && this._collapsed ) app.renderPopout(app);
  }

  /* -------------------------------------------- */

  /**
   * Handle right-click events on tab controls to trigger pop-out containers for each tab
   * @param {Event} event     The originating contextmenu event
   * @private
   */
  _onRightClickTab(event) {
    const li = event.target.closest(".item");
    if ( !li ) return;
    event.preventDefault();
    const tabApp = ui[li.dataset.tab];
    tabApp.renderPopout(tabApp);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling of the Sidebar container's collapsed or expanded state
   * @param {Event} event
   * @private
   */
  _onToggleCollapse(event) {
    event.preventDefault();
    if ( this._collapsed ) this.expand();
    else this.collapse();
  }
}

/**
 * The Application responsible for displaying and editing a single Actor document.
 * This Application is responsible for rendering an actor's attributes and allowing the actor to be edited.
 * @extends {DocumentSheet}
 * @category - Applications
 * @param {Actor} actor                     The Actor instance being displayed within the sheet.
 * @param {DocumentSheetOptions} [options]  Additional application configuration options.
 */
class ActorSheet extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      height: 720,
      width: 800,
      template: "templates/sheets/actor-sheet.html",
      closeOnSubmit: false,
      submitOnClose: true,
      submitOnChange: true,
      resizable: true,
      baseApplication: "ActorSheet",
      dragDrop: [{dragSelector: ".item-list .item", dropSelector: null}],
      secrets: [{parentSelector: ".editor"}],
      token: null
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get title() {
    if ( !this.actor.isToken ) return this.actor.name;
    return `[${game.i18n.localize(TokenDocument.metadata.label)}] ${this.actor.name}`;
  }

  /* -------------------------------------------- */

  /**
   * A convenience reference to the Actor document
   * @type {Actor}
   */
  get actor() {
    return this.object;
  }

  /* -------------------------------------------- */

  /**
   * If this Actor Sheet represents a synthetic Token actor, reference the active Token
   * @type {Token|null}
   */
  get token() {
    return this.object.token || this.options.token || null;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  async close(options) {
    this.options.token = null;
    return super.close(options);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    const context = super.getData(options);
    context.actor = this.object;
    context.items = context.data.items;
    context.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));
    context.effects = context.data.effects;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getHeaderButtons() {
    let buttons = super._getHeaderButtons();
    const canConfigure = game.user.isGM || (this.actor.isOwner && game.user.can("TOKEN_CONFIGURE"));
    if ( this.options.editable && canConfigure ) {
      const closeIndex = buttons.findIndex(btn => btn.label === "Close");
      buttons.splice(closeIndex, 0, {
        label: this.token ? "Token" : "TOKEN.TitlePrototype",
        class: "configure-token",
        icon: "fas fa-user-circle",
        onclick: ev => this._onConfigureToken(ev)
      });
    }
    return buttons;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getSubmitData(updateData = {}) {
    const data = super._getSubmitData(updateData);
    // Prevent submitting overridden values
    const overrides = foundry.utils.flattenObject(this.actor.overrides);
    for ( let k of Object.keys(overrides) ) delete data[k];
    return data;
  }

  /* -------------------------------------------- */
  /*  Event Listeners                             */
  /* -------------------------------------------- */

  /**
   * Handle requests to configure the Token for the Actor
   * @param {PointerEvent} event      The originating click event
   * @private
   */
  _onConfigureToken(event) {
    event.preventDefault();
    const renderOptions = {
      left: Math.max(this.position.left - 560 - 10, 10),
      top: this.position.top
    };
    if ( this.token ) return this.token.sheet.render(true, renderOptions);
    else new CONFIG.Token.prototypeSheetClass(this.actor.prototypeToken, renderOptions).render(true);
  }

  /* -------------------------------------------- */
  /*  Drag and Drop                               */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _canDragStart(selector) {
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _canDragDrop(selector) {
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragStart(event) {
    const li = event.currentTarget;
    if ( "link" in event.target.dataset ) return;

    // Create drag data
    let dragData;

    // Owned Items
    if ( li.dataset.itemId ) {
      const item = this.actor.items.get(li.dataset.itemId);
      dragData = item.toDragData();
    }

    // Active Effect
    if ( li.dataset.effectId ) {
      const effect = this.actor.effects.get(li.dataset.effectId);
      dragData = effect.toDragData();
    }

    if ( !dragData ) return;

    // Set data transfer
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onDrop(event) {
    const data = TextEditor.getDragEventData(event);
    const actor = this.actor;
    const allowed = Hooks.call("dropActorSheetData", actor, this, data);
    if ( allowed === false ) return;

    // Handle different data types
    switch ( data.type ) {
      case "ActiveEffect":
        return this._onDropActiveEffect(event, data);
      case "Actor":
        return this._onDropActor(event, data);
      case "Item":
        return this._onDropItem(event, data);
      case "Folder":
        return this._onDropFolder(event, data);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle the dropping of ActiveEffect data onto an Actor Sheet
   * @param {DragEvent} event                  The concluding DragEvent which contains drop data
   * @param {object} data                      The data transfer extracted from the event
   * @returns {Promise<ActiveEffect|boolean>}  The created ActiveEffect object or false if it couldn't be created.
   * @protected
   */
  async _onDropActiveEffect(event, data) {
    const effect = await ActiveEffect.implementation.fromDropData(data);
    if ( !this.actor.isOwner || !effect ) return false;
    if ( effect.target === this.actor ) return false;
    return ActiveEffect.create(effect.toObject(), {parent: this.actor});
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping of an Actor data onto another Actor sheet
   * @param {DragEvent} event            The concluding DragEvent which contains drop data
   * @param {object} data                The data transfer extracted from the event
   * @returns {Promise<object|boolean>}  A data object which describes the result of the drop, or false if the drop was
   *                                     not permitted.
   * @protected
   */
  async _onDropActor(event, data) {
    if ( !this.actor.isOwner ) return false;
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping of an item reference or item data onto an Actor Sheet
   * @param {DragEvent} event            The concluding DragEvent which contains drop data
   * @param {object} data                The data transfer extracted from the event
   * @returns {Promise<Item[]|boolean>}  The created or updated Item instances, or false if the drop was not permitted.
   * @protected
   */
  async _onDropItem(event, data) {
    if ( !this.actor.isOwner ) return false;
    const item = await Item.implementation.fromDropData(data);
    const itemData = item.toObject();

    // Handle item sorting within the same Actor
    if ( this.actor.uuid === item.parent?.uuid ) return this._onSortItem(event, itemData);

    // Create the owned item
    return this._onDropItemCreate(itemData, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping of a Folder on an Actor Sheet.
   * The core sheet currently supports dropping a Folder of Items to create all items as owned items.
   * @param {DragEvent} event     The concluding DragEvent which contains drop data
   * @param {object} data         The data transfer extracted from the event
   * @returns {Promise<Item[]>}
   * @protected
   */
  async _onDropFolder(event, data) {
    if ( !this.actor.isOwner ) return [];
    const folder = await Folder.implementation.fromDropData(data);
    if ( folder.type !== "Item" ) return [];
    const droppedItemData = await Promise.all(folder.contents.map(async item => {
      if ( !(document instanceof Item) ) item = await fromUuid(item.uuid);
      return item.toObject();
    }));
    return this._onDropItemCreate(droppedItemData, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle the final creation of dropped Item data on the Actor.
   * This method is factored out to allow downstream classes the opportunity to override item creation behavior.
   * @param {object[]|object} itemData      The item data requested for creation
   * @param {DragEvent} event               The concluding DragEvent which provided the drop data
   * @returns {Promise<Item[]>}
   * @private
   */
  async _onDropItemCreate(itemData, event) {
    itemData = itemData instanceof Array ? itemData : [itemData];
    return this.actor.createEmbeddedDocuments("Item", itemData);
  }

  /* -------------------------------------------- */

  /**
   * Handle a drop event for an existing embedded Item to sort that Item relative to its siblings
   * @param {Event} event
   * @param {Object} itemData
   * @private
   */
  _onSortItem(event, itemData) {

    // Get the drag source and drop target
    const items = this.actor.items;
    const source = items.get(itemData._id);
    const dropTarget = event.target.closest("[data-item-id]");
    if ( !dropTarget ) return;
    const target = items.get(dropTarget.dataset.itemId);

    // Don't sort on yourself
    if ( source.id === target.id ) return;

    // Identify sibling items based on adjacent HTML elements
    const siblings = [];
    for ( let el of dropTarget.parentElement.children ) {
      const siblingId = el.dataset.itemId;
      if ( siblingId && (siblingId !== source.id) ) siblings.push(items.get(el.dataset.itemId));
    }

    // Perform the sort
    const sortUpdates = SortingHelpers.performIntegerSort(source, {target, siblings});
    const updateData = sortUpdates.map(u => {
      const update = u.update;
      update._id = u.target._id;
      return update;
    });

    // Perform the update
    return this.actor.updateEmbeddedDocuments("Item", updateData);
  }
}

/**
 * An interface for packaging Adventure content and loading it to a compendium pack.
 * // TODO - add a warning if you are building the adventure with any missing content
 * // TODO - add a warning if you are building an adventure that sources content from a different package' compendium
 */
class AdventureExporter extends DocumentSheet {
  constructor(document, options={}) {
    super(document, options);
    if ( !document.pack ) {
      throw new Error("You may not export an Adventure that does not belong to a Compendium pack");
    }
  }

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "templates/adventure/exporter.html",
      id: "adventure-exporter",
      classes: ["sheet", "adventure", "adventure-exporter"],
      width: 560,
      height: "auto",
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "summary"}],
      dragDrop: [{ dropSelector: "form" }],
      scrollY: [".tab.contents"],
      submitOnClose: false,
      closeOnSubmit: true
    });
  }

  /**
   * An alias for the Adventure document
   * @type {Adventure}
   */
  adventure = this.object;

  /**
   * @typedef {Object} AdventureContentTreeNode
   * @property {string} id        An alias for folder.id
   * @property {string} name      An alias for folder.name
   * @property {Folder} folder    The Folder at this node level
   * @property {string} state     The modification state of the Folder
   * @property {AdventureContentTreeNode[]} children  An array of child nodes
   * @property {{id: string, name: string, document: ClientDocument, state: string}[]} documents  An array of documents
   */
  /**
   * @typedef {AdventureContentTreeNode} AdventureContentTreeRoot
   * @property {null} id                The folder ID is null at the root level
   * @property {string} documentName    The Document name contained in this tree
   * @property {string} collection      The Document collection name of this tree
   * @property {string} name            The name displayed at the root level of the tree
   * @property {string} icon            The icon displayed at the root level of the tree
   * @property {string} collapseIcon    The icon which represents the current collapsed state of the tree
   * @property {string} cssClass        CSS classes which describe the display of the tree
   * @property {number} documentCount   The number of documents which are present in the tree
   */
  /**
   * The prepared document tree which is displayed in the form.
   * @type {Record<string, AdventureContentTreeRoot>}
   */
  contentTree = {};

  /**
   * A mapping which allows convenient access to content tree nodes by their folder ID
   * @type {Record<string, AdventureContentTreeNode>}
   */
  #treeNodes = {};

  /**
   * Track data for content which has been added to the adventure.
   * @type {Record<string, Set<ClientDocument>>}
   */
  #addedContent = Object.keys(Adventure.contentFields).reduce((obj, f) => {
    obj[f] = new Set();
    return obj;
  }, {});

  /**
   * Track the IDs of content which has been removed from the adventure.
   * @type {Record<string, Set<string>>}
   */
  #removedContent = Object.keys(Adventure.contentFields).reduce((obj, f) => {
    obj[f] = new Set();
    return obj;
  }, {});

  /**
   * Track which sections of the contents are collapsed.
   * @type {Set<string>}
   * @private
   */
  #collapsedSections = new Set();

  /** @override */
  get isEditable() {
    return game.user.isGM;
  }

  /* -------------------------------------------- */
  /*  Application Rendering                       */
  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    this.contentTree = this.#organizeContentTree();
    return {
      adventure: this.adventure,
      contentTree: this.contentTree
    };
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async activateEditor(name, options={}, initialContent="") {
    options.plugins = {
      menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema),
      keyMaps: ProseMirror.ProseMirrorKeyMaps.build(ProseMirror.defaultSchema)
    };
    return super.activateEditor(name, options, initialContent);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getHeaderButtons() {
    return super._getHeaderButtons().filter(btn => btn.label !== "Import");
  }

  /* -------------------------------------------- */

  /**
   * Organize content in the adventure into a tree structure which is displayed in the UI.
   * @returns {Record<string, AdventureContentTreeRoot>}
   */
  #organizeContentTree() {
    const content = {};
    let remainingFolders = Array.from(this.adventure.folders).concat(Array.from(this.#addedContent.folders || []));

    // Prepare each content section
    for ( const [name, cls] of Object.entries(Adventure.contentFields) ) {
      if ( name === "folders" ) continue;

      // Partition content for the section
      let documents = Array.from(this.adventure[name]).concat(Array.from(this.#addedContent[name] || []));
      let folders;
      [remainingFolders, folders] = remainingFolders.partition(f => f.type === cls.documentName);
      if ( !(documents.length || folders.length) ) continue;

      // Prepare the root node
      const collapsed = this.#collapsedSections.has(cls.documentName);
      const section = content[name] = {
        documentName: cls.documentName,
        collection: cls.collectionName,
        id: null,
        name: game.i18n.localize(cls.metadata.labelPlural),
        icon: CONFIG[cls.documentName].sidebarIcon,
        collapseIcon: collapsed ? "fa-solid fa-angle-up" : "fa-solid fa-angle-down",
        cssClass: [cls.collectionName, collapsed ? "collapsed" : ""].filterJoin(" "),
        documentCount: documents.length - this.#removedContent[name].size,
        folder: null,
        state: "root",
        children: [],
        documents: []
      };

      // Recursively populate the tree
      [folders, documents] = this.#populateNode(section, folders, documents);

      // Add leftover documents to the section root
      for ( const d of documents ) {
        const state = this.#getDocumentState(d);
        section.documents.push({document: d, id: d.id, name: d.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`});
      }
    }
    return content;
  }

  /* -------------------------------------------- */

  /**
   * Populate one node of the content tree with folders and documents
   * @param {AdventureContentTreeNode }node         The node being populated
   * @param {Folder[]} remainingFolders             Folders which have yet to be populated to a node
   * @param {ClientDocument[]} remainingDocuments   Documents which have yet to be populated to a node
   * @returns {Array<Folder[], ClientDocument[]>}   Folders and Documents which still have yet to be populated
   */
  #populateNode(node, remainingFolders, remainingDocuments) {

    // Allocate Documents to this node
    let documents;
    [remainingDocuments, documents] = remainingDocuments.partition(d => d._source.folder === node.id );
    for ( const d of documents ) {
      const state = this.#getDocumentState(d);
      node.documents.push({document: d, id: d.id, name: d.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`});
    }

    // Allocate Folders to this node
    let folders;
    [remainingFolders, folders] = remainingFolders.partition(f => f._source.folder === node.id);
    for ( const folder of folders ) {
      const state = this.#getDocumentState(folder);
      const child = {folder, id: folder.id, name: folder.name, state: state, stateLabel: `ADVENTURE.Document${state.titleCase()}`,
        children: [], documents: []};
      [remainingFolders, remainingDocuments] = this.#populateNode(child, remainingFolders, remainingDocuments);
      node.children.push(child);
      this.#treeNodes[folder.id] = child;
    }
    return [remainingFolders, remainingDocuments];
  }

  /* -------------------------------------------- */

  /**
   * Flag the current state of each document which is displayed
   * @param {ClientDocument} document The document being modified
   * @returns {string}                The document state
   */
  #getDocumentState(document) {
    const cn = document.collectionName;
    if ( this.#removedContent[cn].has(document.id) ) return "remove";
    if ( this.#addedContent[cn].has(document) ) return "add";
    const worldCollection = game.collections.get(document.documentName);
    if ( !worldCollection.has(document.id) ) return "missing";
    return "update";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async close(options = {}) {
    this.adventure.reset();  // Reset any pending changes
    return super.close(options);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.on("click", "a.control", this.#onClickControl.bind(this));
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, adventureData) {

    // Build the adventure data content
    for ( const [name, cls] of Object.entries(Adventure.contentFields) ) {
      const collection = game.collections.get(cls.documentName);
      adventureData[name] = [];
      const addDoc = id => {
        if ( this.#removedContent[name].has(id) ) return;
        const doc = collection.get(id);
        if ( !doc ) return;
        adventureData[name].push(doc.toObject());
      };
      for ( const d of this.adventure[name] ) addDoc(d.id);
      for ( const d of this.#addedContent[name] ) addDoc(d.id);
    }

    const pack = game.packs.get(this.adventure.pack);
    const restrictedDocuments = adventureData.actors?.length || adventureData.items?.length
      || adventureData.folders?.some(f => CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(f.type));
    if ( restrictedDocuments && !pack?.metadata.system ) {
      return ui.notifications.error("ADVENTURE.ExportPackNoSystem", {localize: true, permanent: true});
    }

    // Create or update the document
    if ( this.adventure.id ) {
      const updated = await this.adventure.update(adventureData, {diff: false, recursive: false});
      pack.indexDocument(updated);
      ui.notifications.info(game.i18n.format("ADVENTURE.UpdateSuccess", {name: this.adventure.name}));
    } else {
      await this.adventure.constructor.createDocuments([adventureData], {
        pack: this.adventure.pack,
        keepId: true,
        keepEmbeddedIds: true
      });
      ui.notifications.info(game.i18n.format("ADVENTURE.CreateSuccess", {name: this.adventure.name}));
    }
  }

  /* -------------------------------------------- */

  /**
   * Save editing progress so that re-renders of the form do not wipe out un-saved changes.
   */
  #saveProgress() {
    const formData = this._getSubmitData();
    this.adventure.updateSource(formData);
  }

  /* -------------------------------------------- */

  /**
   * Handle pointer events on a control button
   * @param {PointerEvent} event    The originating pointer event
   */
  #onClickControl(event) {
    event.preventDefault();
    const button = event.currentTarget;
    switch ( button.dataset.action ) {
      case "clear":
        return this.#onClearSection(button);
      case "collapse":
        return this.#onCollapseSection(button);
      case "remove":
        return this.#onRemoveContent(button);
    }
  }

  /* -------------------------------------------- */

  /**
   * Clear all content from a particular document-type section.
   * @param {HTMLAnchorElement} button      The clicked control button
   */
  #onClearSection(button) {
    const section = button.closest(".document-type");
    const documentType = section.dataset.documentType;
    const cls = getDocumentClass(documentType);
    this.#removeNode(this.contentTree[cls.collectionName]);
    this.#saveProgress();
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Toggle the collapsed or expanded state of a document-type section
   * @param {HTMLAnchorElement} button      The clicked control button
   */
  #onCollapseSection(button) {
    const section = button.closest(".document-type");
    const icon = button.firstElementChild;
    const documentType = section.dataset.documentType;
    const isCollapsed = this.#collapsedSections.has(documentType);
    if ( isCollapsed ) {
      this.#collapsedSections.delete(documentType);
      section.classList.remove("collapsed");
      icon.classList.replace("fa-angle-up", "fa-angle-down");
    } else {
      this.#collapsedSections.add(documentType);
      section.classList.add("collapsed");
      icon.classList.replace("fa-angle-down", "fa-angle-up");
    }
  }

  /* -------------------------------------------- */

  /**
   * Remove a single piece of content.
   * @param {HTMLAnchorElement} button      The clicked control button
   */
  #onRemoveContent(button) {
    const h4 = button.closest("h4");
    const isFolder = h4.classList.contains("folder");
    const documentName = isFolder ? "Folder" : button.closest(".document-type").dataset.documentType;
    const document = this.#getDocument(documentName, h4.dataset.documentId);
    if ( document ) {
      this.removeContent(document);
      this.#saveProgress();
      this.render();
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the Document instance from the clicked content tag.
   * @param {string} documentName         The document type
   * @param {string} documentId           The document ID
   * @returns {ClientDocument|null}       The Document instance, or null
   */
  #getDocument(documentName, documentId) {
    const cls = getDocumentClass(documentName);
    const cn = cls.collectionName;
    const existing = this.adventure[cn].find(d => d.id === documentId);
    if ( existing ) return existing;
    const added = this.#addedContent[cn].find(d => d.id === documentId);
    return added || null;
  }

  /* -------------------------------------------- */
  /*  Content Drop Handling                       */
  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onDrop(event) {
    const data = TextEditor.getDragEventData(event);
    const cls = getDocumentClass(data?.type);
    if ( !cls || !(cls.collectionName in Adventure.contentFields) ) return;
    const document = await cls.fromDropData(data);
    if ( document.pack || document.isEmbedded ) {
      return ui.notifications.error("ADVENTURE.ExportPrimaryDocumentsOnly", {localize: true});
    }
    const pack = game.packs.get(this.adventure.pack);
    const type = data?.type === "Folder" ? document.type : data?.type;
    if ( !pack?.metadata.system && CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(type) ) {
      return ui.notifications.error("ADVENTURE.ExportPackNoSystem", {localize: true});
    }
    this.addContent(document);
    this.#saveProgress();
    this.render();
  }

  /* -------------------------------------------- */
  /*  Content Management Workflows                */
  /* -------------------------------------------- */

  /**
   * Stage a document for addition to the Adventure.
   * This adds the document locally, the change is not yet submitted to the database.
   * @param {Folder|ClientDocument} document    Some document to be added to the Adventure.
   */
  addContent(document) {
    if ( document instanceof foundry.documents.BaseFolder ) this.#addFolder(document);
    if ( document.folder ) this.#addDocument(document.folder);
    this.#addDocument(document);
  }

  /* -------------------------------------------- */

  /**
   * Remove a single Document from the Adventure.
   * @param {ClientDocument} document       The Document being removed from the Adventure.
   */
  removeContent(document) {
    if ( document instanceof foundry.documents.BaseFolder ) {
      const node = this.#treeNodes[document.id];
      if ( !node ) return;
      if ( this.#removedContent.folders.has(node.id) ) return this.#restoreNode(node);
      return this.#removeNode(node);
    }
    else this.#removeDocument(document);
  }

  /* -------------------------------------------- */

  /**
   * Remove a single document from the content tree
   * @param {AdventureContentTreeNode} node     The node to remove
   */
  #removeNode(node) {
    for ( const child of node.children ) this.#removeNode(child);
    for ( const d of node.documents ) this.#removeDocument(d.document);
    if ( node.folder ) this.#removeDocument(node.folder);
  }

  /* -------------------------------------------- */

  /**
   * Restore a removed node back to the content tree
   * @param {AdventureContentTreeNode} node     The node to restore
   */
  #restoreNode(node) {
    for ( const child of node.children ) this.#restoreNode(child);
    for ( const d of node.documents ) this.#removedContent[d.document.collectionName].delete(d.id);
    return this.#removedContent.folders.delete(node.id);
  }

  /* -------------------------------------------- */

  /**
   * Remove a single document from the content tree
   * @param {ClientDocument} document     The document to remove
   */
  #removeDocument(document) {
    const cn = document.collectionName;

    // If the Document was already removed, re-add it
    if ( this.#removedContent[cn].has(document.id) ) {
      this.#removedContent[cn].delete(document.id);
    }

    // If the content was temporarily added, remove it
    else if ( this.#addedContent[cn].has(document) ) {
      this.#addedContent[cn].delete(document);
    }

    // Otherwise, mark the content as removed
    else this.#removedContent[cn].add(document.id);
  }

  /* -------------------------------------------- */

  /**
   * Add an entire folder tree including contained documents and subfolders to the Adventure.
   * @param {Folder} folder   The folder to add
   * @private
   */
  #addFolder(folder) {
    this.#addDocument(folder);
    for ( const doc of folder.contents ) {
      this.#addDocument(doc);
    }
    for ( const sub of folder.getSubfolders() ) {
      this.#addFolder(sub);
    }
  }

  /* -------------------------------------------- */

  /**
   * Add a single document to the Adventure.
   * @param {ClientDocument} document   The Document to add
   * @private
   */
  #addDocument(document) {
    const cn = document.collectionName;

    // If the document was previously removed, restore it
    if ( this.#removedContent[cn].has(document.id) ) {
      return this.#removedContent[cn].delete(document.id);
    }

    // Otherwise, add documents which don't yet exist
    const existing = this.adventure[cn].find(d => d.id === document.id);
    if ( !existing ) this.#addedContent[cn].add(document);
  }
}

/**
 * An interface for importing an adventure from a compendium pack.
 */
class AdventureImporter extends DocumentSheet {

  /**
   * An alias for the Adventure document
   * @type {Adventure}
   */
  adventure = this.object;

  /** @override */
  get isEditable() {
    return game.user.isGM;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "templates/adventure/importer.html",
      id: "adventure-importer",
      classes: ["sheet", "adventure", "adventure-importer"],
      width: 800,
      height: "auto",
      submitOnClose: false,
      closeOnSubmit: true
    });
  }

  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    return {
      adventure: this.adventure,
      contents: this._getContentList(),
      imported: !!game.settings.get("core", "adventureImports")?.[this.adventure.uuid]
    };
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('[value="all"]').on("change", this._onToggleImportAll.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the import all checkbox.
   * @param {Event} event  The change event.
   * @protected
   */
  _onToggleImportAll(event) {
    const target = event.currentTarget;
    const section = target.closest(".import-controls");
    const checked = target.checked;
    section.querySelectorAll("input").forEach(input => {
      if ( input === target ) return;
      if ( input.value !== "folders" ) input.disabled = checked;
      if ( checked ) input.checked = true;
    });
  }

  /* -------------------------------------------- */

  /**
   * Prepare a list of content types provided by this adventure.
   * @returns {{icon: string, label: string, count: number}[]}
   * @protected
   */
  _getContentList() {
    return Object.entries(Adventure.contentFields).reduce((arr, [field, cls]) => {
      const count = this.adventure[field].size;
      if ( !count ) return arr;
      arr.push({
        icon: CONFIG[cls.documentName].sidebarIcon,
        label: game.i18n.localize(count > 1 ? cls.metadata.labelPlural : cls.metadata.label),
        count, field
      });
      return arr;
    }, []);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getHeaderButtons() {
    const buttons = super._getHeaderButtons();
    buttons.findSplice(b => b.class === "import");
    return buttons;
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {

    // Backwards compatibility. If the AdventureImporter subclass defines _prepareImportData or _importContent
    /** @deprecated since v11 */
    const prepareImportDefined = foundry.utils.getDefiningClass(this, "_prepareImportData");
    const importContentDefined = foundry.utils.getDefiningClass(this, "_importContent");
    if ( (prepareImportDefined !== AdventureImporter) || (importContentDefined !== AdventureImporter) ) {
      const warning = `The ${this.name} class overrides the AdventureImporter#_prepareImportData or 
      AdventureImporter#_importContent methods. As such a legacy import workflow will be used, but this workflow is 
      deprecated. Your importer should now call the new Adventure#import, Adventure#prepareImport, 
      or Adventure#importContent methods.`;
      foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
      return this._importLegacy(formData);
    }

    // Perform the standard Adventure import workflow
    return this.adventure.import(formData);
  }

  /* -------------------------------------------- */

  /**
   * Mirror Adventure#import but call AdventureImporter#_importContent and AdventureImport#_prepareImportData
   * @deprecated since v11
   * @ignore
   */
  async _importLegacy(formData) {

    // Prepare the content for import
    const {toCreate, toUpdate, documentCount} = await this._prepareImportData(formData);

    // Allow modules to preprocess adventure data or to intercept the import process
    const allowed = Hooks.call("preImportAdventure", this.adventure, formData, toCreate, toUpdate);
    if ( allowed === false ) {
      return console.log(`"${this.adventure.name}" Adventure import was prevented by the "preImportAdventure" hook`);
    }

    // Warn the user if the import operation will overwrite existing World content
    if ( !foundry.utils.isEmpty(toUpdate) ) {
      const confirm = await Dialog.confirm({
        title: game.i18n.localize("ADVENTURE.ImportOverwriteTitle"),
        content: `<h4><strong>${game.i18n.localize("Warning")}:</strong></h4>
        <p>${game.i18n.format("ADVENTURE.ImportOverwriteWarning", {name: this.adventure.name})}</p>`
      });
      if ( !confirm ) return;
    }

    // Perform the import
    const {created, updated} = await this._importContent(toCreate, toUpdate, documentCount);

    // Refresh the sidebar display
    ui.sidebar.render();

    // Allow modules to react to the import process
    Hooks.callAll("importAdventure", this.adventure, formData, created, updated);
  }

  /* -------------------------------------------- */
  /*  Deprecations                                */
  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  async _prepareImportData(formData) {
    foundry.utils.logCompatibilityWarning("AdventureImporter#_prepareImportData is deprecated. "
      + "Please use Adventure#prepareImport instead.", {since: 11, until: 13});
    return this.adventure.prepareImport(formData);
  }

  /* -------------------------------------------- */

  /**
   * @deprecated since v11
   * @ignore
   */
  async _importContent(toCreate, toUpdate, documentCount) {
    foundry.utils.logCompatibilityWarning("AdventureImporter#_importContent is deprecated. "
      + "Please use Adventure#importContent instead.", {since: 11, until: 13});
    return this.adventure.importContent({ toCreate, toUpdate, documentCount });
  }
}

/**
 * The Application responsible for displaying a basic sheet for any Document sub-types that do not have a sheet
 * registered.
 * @extends {DocumentSheet}
 */
class BaseSheet extends DocumentSheet {
  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "templates/sheets/base-sheet.html",
      classes: ["sheet", "base-sheet"],
      width: 450,
      height: "auto",
      resizable: true,
      submitOnChange: true,
      closeOnSubmit: false
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async getData(options={}) {
    const context = await super.getData(options);
    context.hasName = "name" in this.object;
    context.hasImage = "img" in this.object;
    context.hasDescription = "description" in this.object;
    if ( context.hasDescription ) {
      context.descriptionHTML = await TextEditor.enrichHTML(this.object.description, {
        secrets: this.object.isOwner,
        relativeTo: this.object
      });
    }
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _render(force, options) {
    await super._render(force, options);
    await this._waitForImages();
    this.setPosition();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async activateEditor(name, options={}, initialContent="") {
    options.relativeLinks = true;
    options.plugins = {
      menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
        compact: true,
        destroyOnSave: false,
        onSave: () => this.saveEditor(name, {remove: false})
      })
    };
    return super.activateEditor(name, options, initialContent);
  }
}

/**
 * A DocumentSheet application responsible for displaying and editing a single embedded Card document.
 * @extends {DocumentSheet}
 * @param {Card} object                     The {@link Card} object being configured.
 * @param {DocumentSheetOptions} [options]  Application configuration options.
 */
class CardConfig extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["sheet", "card-config"],
      template: "templates/cards/card-config.html",
      width: 480,
      height: "auto",
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "details"}]
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    return foundry.utils.mergeObject(super.getData(options), {
      data: this.document.toObject(),  // Source data, not derived
      types: CONFIG.Card.typeLabels
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".face-control").click(this._onFaceControl.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle card face control actions which modify single cards on the sheet.
   * @param {PointerEvent} event          The originating click event
   * @returns {Promise}                   A Promise which resolves once the handler has completed
   * @protected
   */
  async _onFaceControl(event) {
    const button = event.currentTarget;
    const face = button.closest(".face");
    const faces = this.object.toObject().faces;

    // Save any pending change to the form
    await this._onSubmit(event, {preventClose: true, preventRender: true});

    // Handle the control action
    switch ( button.dataset.action ) {
      case "addFace":
        faces.push({});
        return this.object.update({faces});
      case "deleteFace":
        return Dialog.confirm({
          title: game.i18n.localize("CARD.FaceDelete"),
          content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("CARD.FaceDeleteWarning")}</p>`,
          yes: () => {
            const i = Number(face.dataset.face);
            faces.splice(i, 1);
            return this.object.update({faces});
          }
        });
    }
  }
}

/**
 * A DocumentSheet application responsible for displaying and editing a single Cards stack.
 */
class CardsConfig extends DocumentSheet {
  /**
   * The CardsConfig sheet is constructed by providing a Cards document and sheet-level options.
   * @param {Cards} object                    The {@link Cards} object being configured.
   * @param {DocumentSheetOptions} [options]  Application configuration options.
   */
  constructor(object, options) {
    super(object, options);
    this.options.classes.push(object.type);
  }

  /**
   * The allowed sorting methods which can be used for this sheet
   * @enum {string}
   */
  static SORT_TYPES = {
    STANDARD: "standard",
    SHUFFLED: "shuffled"
  };

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["sheet", "cards-config"],
      template: "templates/cards/cards-deck.html",
      width: 620,
      height: "auto",
      closeOnSubmit: false,
      viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
      dragDrop: [{dragSelector: "ol.cards li.card", dropSelector: "ol.cards"}],
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "cards"}],
      scrollY: ["ol.cards"],
      sort: this.SORT_TYPES.SHUFFLED
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {

    // Sort cards
    const sortFn = {
      standard: this.object.sortStandard,
      shuffled: this.object.sortShuffled
    }[options?.sort || "standard"];
    const cards = this.object.cards.contents.sort((a, b) => sortFn.call(this.object, a, b));

    // Return rendering context
    return foundry.utils.mergeObject(super.getData(options), {
      cards: cards,
      types: CONFIG.Cards.typeLabels,
      inCompendium: !!this.object.pack
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);

    // Card Actions
    html.find(".card-control").click(this._onCardControl.bind(this));

    // Intersection Observer
    const cards = html.find("ol.cards");
    const entries = cards.find("li.card");
    const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), {root: cards[0]});
    entries.each((i, li) => observer.observe(li));
  }

  /* -------------------------------------------- */

  /**
   * Handle card control actions which modify single cards on the sheet.
   * @param {PointerEvent} event          The originating click event
   * @returns {Promise}                   A Promise which resolves once the handler has completed
   * @protected
   */
  async _onCardControl(event) {
    const button = event.currentTarget;
    const li = button.closest(".card");
    const card = li ? this.object.cards.get(li.dataset.cardId) : null;
    const cls = getDocumentClass("Card");

    // Save any pending change to the form
    await this._onSubmit(event, {preventClose: true, preventRender: true});

    // Handle the control action
    switch ( button.dataset.action ) {
      case "create":
        return cls.createDialog({ faces: [{}], face: 0 }, {parent: this.object, pack: this.object.pack});
      case "edit":
        return card.sheet.render(true);
      case "delete":
        return card.deleteDialog();
      case "deal":
        return this.object.dealDialog();
      case "draw":
        return this.object.drawDialog();
      case "pass":
        return this.object.passDialog();
      case "play":
        return this.object.playDialog(card);
      case "reset":
        return this.object.resetDialog();
      case "shuffle":
        this.options.sort = this.constructor.SORT_TYPES.SHUFFLED;
        return this.object.shuffle();
      case "toggleSort":
        this.options.sort = {standard: "shuffled", shuffled: "standard"}[this.options.sort];
        return this.render();
      case "nextFace":
        return card.update({face: card.face === null ? 0 : card.face+1});
      case "prevFace":
        return card.update({face: card.face === 0 ? null : card.face-1});
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle lazy-loading card face images.
   * See {@link SidebarTab#_onLazyLoadImage}
   * @param {IntersectionObserverEntry[]} entries   The entries which are now in the observer frame
   * @param {IntersectionObserver} observer         The intersection observer instance
   * @protected
   */
  _onLazyLoadImage(entries, observer) {
    return ui.cards._onLazyLoadImage.call(this, entries, observer);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _canDragStart(selector) {
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragStart(event) {
    const li = event.currentTarget;
    const card = this.object.cards.get(li.dataset.cardId);
    if ( !card ) return;

    // Set data transfer
    event.dataTransfer.setData("text/plain", JSON.stringify(card.toDragData()));
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _canDragDrop(selector) {
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onDrop(event) {
    const data = TextEditor.getDragEventData(event);
    if ( data.type !== "Card" ) return;
    const card = await Card.implementation.fromDropData(data);
    if ( card.parent.id === this.object.id ) return this._onSortCard(event, card);
    try {
      return await card.pass(this.object);
    } catch(err) {
      Hooks.onError("CardsConfig#_onDrop", err, {log: "error", notify: "error"});
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle sorting a Card relative to other siblings within this document
   * @param {Event} event     The drag drop event
   * @param {Card} card       The card being dragged
   * @private
   */
  _onSortCard(event, card) {

    // Identify a specific card as the drop target
    let target = null;
    const li = event.target.closest("[data-card-id]");
    if ( li ) target = this.object.cards.get(li.dataset.cardId) ?? null;

    // Don't sort on yourself.
    if ( card === target ) return;

    // Identify the set of siblings
    const siblings = this.object.cards.filter(c => c.id !== card.id);

    // Perform an integer-based sort
    const updateData = SortingHelpers.performIntegerSort(card, {target, siblings}).map(u => {
      return {_id: u.target.id, sort: u.update.sort};
    });
    return this.object.updateEmbeddedDocuments("Card", updateData);
  }
}

/**
 * A subclass of CardsConfig which provides a sheet representation for Cards documents with the "hand" type.
 */
class CardsHand extends CardsConfig {
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "templates/cards/cards-hand.html"
    });
  }
}

/**
 * A subclass of CardsConfig which provides a sheet representation for Cards documents with the "pile" type.
 */
class CardsPile extends CardsConfig {
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "templates/cards/cards-pile.html"
    });
  }
}

/**
 * The Application responsible for configuring the CombatTracker and its contents.
 * @extends {FormApplication}
 */
class CombatTrackerConfig extends FormApplication {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "combat-config",
      title: game.i18n.localize("COMBAT.Settings"),
      classes: ["sheet", "combat-sheet"],
      template: "templates/sheets/combat-config.html",
      width: 420
    });
  }

  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    const attributes = TokenDocument.implementation.getTrackedAttributes();
    attributes.bar.forEach(a => a.push("value"));
    const combatThemeSetting = game.settings.settings.get("core.combatTheme");
    return {
      canConfigure: game.user.can("SETTINGS_MODIFY"),
      settings: game.settings.get("core", Combat.CONFIG_SETTING),
      attributeChoices: TokenDocument.implementation.getTrackedAttributeChoices(attributes),
      combatTheme: combatThemeSetting,
      selectedTheme: game.settings.get("core", "combatTheme"),
      user: game.user
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    game.settings.set("core", "combatTheme", formData["core.combatTheme"]);
    return game.settings.set("core", Combat.CONFIG_SETTING, {
      resource: formData.resource,
      skipDefeated: formData.skipDefeated
    });
  }

  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".audio-preview").click(this.#onAudioPreview.bind(this));
  }

  /* -------------------------------------------- */

  #audioPreviewState = 0;

  /**
   * Handle previewing a sound file for a Combat Tracker setting
   * @param {Event} event   The initial button click event
   * @private
   */
  #onAudioPreview(event) {
    const themeName = this.form["core.combatTheme"].value;
    const theme = CONFIG.Combat.sounds[themeName];
    if ( !theme || theme === "none" ) return;
    const announcements = CONST.COMBAT_ANNOUNCEMENTS;
    const announcement = announcements[this.#audioPreviewState++ % announcements.length];
    const sounds = theme[announcement];
    if ( !sounds ) return;
    const src = sounds[Math.floor(Math.random() * sounds.length)];
    game.audio.play(src, {context: game.audio.interface});
  }

  /* -------------------------------------------- */

  /** @override */
  async _onChangeInput(event) {
    if ( event.currentTarget.name === "core.combatTheme" ) this.#audioPreviewState = 0;
    return super._onChangeInput(event);
  }
}

/**
 * The Application responsible for configuring a single Combatant document within a parent Combat.
 * @extends {DocumentSheet}
 */
class CombatantConfig extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "combatant-config",
      title: game.i18n.localize("COMBAT.CombatantConfig"),
      classes: ["sheet", "combat-sheet"],
      template: "templates/sheets/combatant-config.html",
      width: 420
    });
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize(this.object.id ? "COMBAT.CombatantUpdate" : "COMBAT.CombatantCreate");
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    if ( this.object.id ) return this.object.update(formData);
    else {
      const cls = getDocumentClass("Combatant");
      return cls.create(formData, {parent: game.combat});
    }
  }
}

/**
 * An Application responsible for allowing GMs to configure the default sheets that are used for the Documents in their
 * world.
 */
class DefaultSheetsConfig extends PackageConfiguration {
  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      title: game.i18n.localize("SETTINGS.DefaultSheetsL"),
      id: "default-sheets-config",
      categoryTemplate: "templates/sidebar/apps/default-sheets-config.html",
      submitButton: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _prepareCategoryData() {
    let total = 0;
    const categories = [];
    for ( const cls of Object.values(foundry.documents) ) {
      const documentName = cls.documentName;
      if ( !cls.hasTypeData ) continue;
      const subTypes = game.documentTypes[documentName].filter(t => t !== CONST.BASE_DOCUMENT_TYPE);
      if ( !subTypes.length ) continue;
      const title = game.i18n.localize(cls.metadata.labelPlural);
      categories.push({
        title,
        id: documentName,
        count: subTypes.length,
        subTypes: subTypes.map(t => {
          const typeLabel = CONFIG[documentName].typeLabels?.[t];
          const name = typeLabel ? game.i18n.localize(typeLabel) : t;
          const {defaultClasses, defaultClass} = DocumentSheetConfig.getSheetClassesForSubType(documentName, t);
          return {type: t, name, defaultClasses, defaultClass};
        })
      });
      total += subTypes.length;
    }
    return {categories, total};
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {
    const current = game.settings.get("core", "sheetClasses");
    const settings = Object.entries(formData).reduce((obj, [name, sheetId]) => {
      const [documentName, ...rest] = name.split(".");
      const subType = rest.join(".");
      const cfg = CONFIG[documentName].sheetClasses?.[subType]?.[sheetId];
      // Do not create an entry in the settings object if the class is already the default.
      if ( cfg?.default && !current[documentName]?.[subType] ) return obj;
      const entry = obj[documentName] ??= {};
      entry[subType] = sheetId;
      return obj;
    }, {});
    return game.settings.set("core", "sheetClasses", settings);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onResetDefaults(event) {
    event.preventDefault();
    await game.settings.set("core", "sheetClasses", {});
    return SettingsConfig.reloadConfirm({world: true});
  }
}

/**
 * The Application responsible for configuring a single ActiveEffect document within a parent Actor or Item.
 * @extends {DocumentSheet}
 *
 * @param {ActiveEffect} object             The target active effect being configured
 * @param {DocumentSheetOptions} [options]  Additional options which modify this application instance
 */
class ActiveEffectConfig extends DocumentSheet {

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["sheet", "active-effect-sheet"],
      template: "templates/sheets/active-effect-config.html",
      width: 580,
      height: "auto",
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "details"}]
    });
  }

  /* ----------------------------------------- */

  /** @override */
  async getData(options={}) {
    const context = await super.getData(options);
    context.descriptionHTML = await TextEditor.enrichHTML(this.object.description, {secrets: this.object.isOwner});
    const legacyTransfer = CONFIG.ActiveEffect.legacyTransferral;
    const labels = {
      transfer: {
        name: game.i18n.localize(`EFFECT.Transfer${legacyTransfer ? "Legacy" : ""}`),
        hint: game.i18n.localize(`EFFECT.TransferHint${legacyTransfer ? "Legacy" : ""}`)
      }
    };

    // Status Conditions
    const statuses = CONFIG.statusEffects.map(s => {
      return {
        id: s.id,
        label: game.i18n.localize(s.name ?? /** @deprecated since v12 */ s.label),
        selected: context.data.statuses.includes(s.id) ? "selected" : ""
      };
    });

    // Return rendering context
    return foundry.utils.mergeObject(context, {
      labels,
      effect: this.object, // Backwards compatibility
      data: this.object,
      isActorEffect: this.object.parent.documentName === "Actor",
      isItemEffect: this.object.parent.documentName === "Item",
      submitText: "EFFECT.Submit",
      statuses,
      modes: Object.entries(CONST.ACTIVE_EFFECT_MODES).reduce((obj, e) => {
        obj[e[1]] = game.i18n.localize(`EFFECT.MODE_${e[0]}`);
        return obj;
      }, {})
    });
  }

  /* ----------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".effect-control").click(this._onEffectControl.bind(this));
  }

  /* ----------------------------------------- */

  /**
   * Provide centralized handling of mouse clicks on control buttons.
   * Delegate responsibility out to action-specific handlers depending on the button action.
   * @param {MouseEvent} event      The originating click event
   * @private
   */
  _onEffectControl(event) {
    event.preventDefault();
    const button = event.currentTarget;
    switch ( button.dataset.action ) {
      case "add":
        return this._addEffectChange();
      case "delete":
        button.closest(".effect-change").remove();
        return this.submit({preventClose: true}).then(() => this.render());
    }
  }

  /* ----------------------------------------- */

  /**
   * Handle adding a new change to the changes array.
   * @private
   */
  async _addEffectChange() {
    const idx = this.document.changes.length;
    return this.submit({preventClose: true, updateData: {
      [`changes.${idx}`]: {key: "", mode: CONST.ACTIVE_EFFECT_MODES.ADD, value: ""}
    }});
  }

  /* ----------------------------------------- */

  /** @inheritdoc */
  _getSubmitData(updateData={}) {
    const fd = new FormDataExtended(this.form, {editors: this.editors});
    let data = foundry.utils.expandObject(fd.object);
    if ( updateData ) foundry.utils.mergeObject(data, updateData);
    data.changes = Array.from(Object.values(data.changes || {}));
    data.statuses ??= [];
    return data;
  }
}

/**
 * The Application responsible for configuring a single Folder document.
 * @extends {DocumentSheet}
 * @param {Folder} object                   The {@link Folder} object to configure.
 * @param {DocumentSheetOptions} [options]  Application configuration options.
 */
class FolderConfig extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["sheet", "folder-edit"],
      template: "templates/sidebar/folder-edit.html",
      width: 360
    });
  }

  /* -------------------------------------------- */

  /** @override */
  get id() {
    return this.object.id ? super.id : "folder-create";
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    if ( this.object.id ) return `${game.i18n.localize("FOLDER.Update")}: ${this.object.name}`;
    return game.i18n.localize("FOLDER.Create");
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async close(options={}) {
    if ( !this.options.submitOnClose ) this.options.resolve?.(null);
    return super.close(options);
  }

  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    const folder = this.document.toObject();
    return {
      folder: folder,
      name: folder._id ? folder.name : "",
      newName: Folder.implementation.defaultName({pack: folder.pack}),
      safeColor: folder.color?.css ?? "#000000",
      sortingModes: {a: "FOLDER.SortAlphabetical", m: "FOLDER.SortManual"},
      submitText: game.i18n.localize(folder._id ? "FOLDER.Update" : "FOLDER.Create")
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    let doc = this.object;
    if ( !formData.name?.trim() ) formData.name = Folder.implementation.defaultName({pack: doc.pack});
    if ( this.object.id ) await this.object.update(formData);
    else {
      this.object.updateSource(formData);
      doc = await Folder.create(this.object, { pack: this.object.pack });
    }
    this.options.resolve?.(doc);
    return doc;
  }
}

/**
 * @typedef {object} NewFontDefinition
 * @property {string} [family]          The font family.
 * @property {number} [weight=400]      The font weight.
 * @property {string} [style="normal"]  The font style.
 * @property {string} [src=""]          The font file.
 * @property {string} [preview]         The text to preview the font.
 */

/**
 * A class responsible for configuring custom fonts for the world.
 * @extends {FormApplication}
 */
class FontConfig extends FormApplication {
  /**
   * An application for configuring custom world fonts.
   * @param {NewFontDefinition} [object]  The default settings for new font definition creation.
   * @param {object} [options]            Additional options to configure behaviour.
   */
  constructor(object={}, options={}) {
    foundry.utils.mergeObject(object, {
      family: "",
      weight: 400,
      style: "normal",
      src: "",
      preview: game.i18n.localize("FONTS.FontPreview"),
      type: FontConfig.FONT_TYPES.FILE
    });
    super(object, options);
  }

  /* -------------------------------------------- */

  /**
   * Whether fonts have been modified since opening the application.
   * @type {boolean}
   */
  #fontsModified = false;

  /* -------------------------------------------- */

  /**
   * The currently selected font.
   * @type {{family: string, index: number}|null}
   */
  #selected = null;

  /* -------------------------------------------- */

  /**
   * Whether the given font is currently selected.
   * @param {{family: string, index: number}} selection  The font selection information.
   * @returns {boolean}
   */
  #isSelected({family, index}) {
    if ( !this.#selected ) return false;
    return (family === this.#selected.family) && (index === this.#selected.index);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      title: game.i18n.localize("SETTINGS.FontConfigL"),
      id: "font-config",
      template: "templates/sidebar/apps/font-config.html",
      popOut: true,
      width: 600,
      height: "auto",
      closeOnSubmit: false,
      submitOnChange: true
    });
  }

  /* -------------------------------------------- */

  /**
   * Whether a font is distributed to connected clients or found on their OS.
   * @enum {string}
   */
  static FONT_TYPES = {
    FILE: "file",
    SYSTEM: "system"
  };

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    const definitions = game.settings.get("core", this.constructor.SETTING);
    const fonts = Object.entries(definitions).flatMap(([family, definition]) => {
      return this._getDataForDefinition(family, definition);
    });
    let selected;
    if ( (this.#selected === null) && fonts.length ) {
      fonts[0].selected = true;
      this.#selected = {family: fonts[0].family, index: fonts[0].index};
    }
    if ( fonts.length ) selected = definitions[this.#selected.family].fonts[this.#selected.index];
    return {
      fonts, selected,
      font: this.object,
      family: this.#selected?.family,
      weights: Object.entries(CONST.FONT_WEIGHTS).map(([k, v]) => ({value: v, label: `${k} ${v}`})),
      styles: [{value: "normal", label: "Normal"}, {value: "italic", label: "Italic"}]
    };
  }

  /* -------------------------------------------- */

  /**
   * Template data for a given font definition.
   * @param {string} family                    The font family.
   * @param {FontFamilyDefinition} definition  The font family definition.
   * @returns {object[]}
   * @protected
   */
  _getDataForDefinition(family, definition) {
    const fonts = definition.fonts.length ? definition.fonts : [{}];
    return fonts.map((f, i) => {
      const data = {family, index: i};
      if ( this.#isSelected(data) ) data.selected = true;
      data.font = this.constructor._formatFont(family, f);
      return data;
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("[contenteditable]").on("blur", this._onSubmit.bind(this));
    html.find(".control").on("click", this._onClickControl.bind(this));
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {
    foundry.utils.mergeObject(this.object, formData);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async close(options={}) {
    await super.close(options);
    if ( this.#fontsModified ) return SettingsConfig.reloadConfirm({world: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle application controls.
   * @param {MouseEvent} event  The click event.
   * @protected
   */
  _onClickControl(event) {
    switch ( event.currentTarget.dataset.action ) {
      case "add": return this._onAddFont();
      case "delete": return this._onDeleteFont(event);
      case "select": return this._onSelectFont(event);
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onChangeInput(event) {
    this._updateFontFields();
    return super._onChangeInput(event);
  }

  /* -------------------------------------------- */

  /**
   * Update available font fields based on the font type selected.
   * @protected
   */
  _updateFontFields() {
    const type = this.form.elements.type.value;
    const isSystemFont = type === this.constructor.FONT_TYPES.SYSTEM;
    ["weight", "style", "src"].forEach(name => {
      const input = this.form.elements[name];
      if ( input ) input.closest(".form-group")?.classList.toggle("hidden", isSystemFont);
    });
    this.setPosition();
  }

  /* -------------------------------------------- */

  /**
   * Add a new custom font definition.
   * @protected
   */
  async _onAddFont() {
    const {family, src, weight, style, type} = this._getSubmitData();
    const definitions = game.settings.get("core", this.constructor.SETTING);
    definitions[family] ??= {editor: true, fonts: []};
    const definition = definitions[family];
    const count = type === this.constructor.FONT_TYPES.FILE ? definition.fonts.push({urls: [src], weight, style}) : 1;
    await game.settings.set("core", this.constructor.SETTING, definitions);
    await this.constructor.loadFont(family, definition);
    this.#selected = {family, index: count - 1};
    this.#fontsModified = true;
    this.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Delete a font.
   * @param {MouseEvent} event  The click event.
   * @protected
   */
  async _onDeleteFont(event) {
    event.preventDefault();
    event.stopPropagation();
    const target = event.currentTarget.closest("[data-family]");
    const {family, index} = target.dataset;
    const definitions = game.settings.get("core", this.constructor.SETTING);
    const definition = definitions[family];
    if ( !definition ) return;
    this.#fontsModified = true;
    definition.fonts.splice(Number(index), 1);
    if ( !definition.fonts.length ) delete definitions[family];
    await game.settings.set("core", this.constructor.SETTING, definitions);
    if ( this.#isSelected({family, index: Number(index)}) ) this.#selected = null;
    this.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Select a font to preview.
   * @param {MouseEvent} event  The click event.
   * @protected
   */
  _onSelectFont(event) {
    const {family, index} = event.currentTarget.dataset;
    this.#selected = {family, index: Number(index)};
    this.render(true);
  }

  /* -------------------------------------------- */
  /*  Font Management Methods                     */
  /* -------------------------------------------- */

  /**
   * Define the setting key where this world's font information will be stored.
   * @type {string}
   */
  static SETTING = "fonts";

  /* -------------------------------------------- */

  /**
   * A list of fonts that were correctly loaded and are available for use.
   * @type {Set<string>}
   * @private
   */
  static #available = new Set();

  /* -------------------------------------------- */

  /**
   * Get the list of fonts that successfully loaded.
   * @returns {string[]}
   */
  static getAvailableFonts() {
    return Array.from(this.#available);
  }

  /* -------------------------------------------- */

  /**
   * Get the list of fonts formatted for display with selectOptions.
   * @returns {Record<string, string>}
   */
  static getAvailableFontChoices() {
    return this.getAvailableFonts().reduce((obj, f) => {
      obj[f] = f;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Load a font definition.
   * @param {string} family                    The font family name (case-sensitive).
   * @param {FontFamilyDefinition} definition  The font family definition.
   * @returns {Promise<boolean>}               Returns true if the font was successfully loaded.
   */
  static async loadFont(family, definition) {
    const font = `1rem "${family}"`;
    try {
      for ( const font of definition.fonts ) {
        const fontFace = this._createFontFace(family, font);
        await fontFace.load();
        document.fonts.add(fontFace);
      }
      await document.fonts.load(font);
    } catch(err) {
      console.warn(`Font family "${family}" failed to load: `, err);
      return false;
    }
    if ( !document.fonts.check(font) ) {
      console.warn(`Font family "${family}" failed to load.`);
      return false;
    }
    if ( definition.editor ) this.#available.add(family);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Ensure that fonts have loaded and are ready for use.
   * Enforce a maximum timeout in milliseconds.
   * Proceed after that point even if fonts are not yet available.
   * @param {number} [ms=4500]  The maximum time to spend loading fonts before proceeding.
   * @returns {Promise<void>}
   * @internal
   */
  static async _loadFonts(ms=4500) {
    const allFonts = this._collectDefinitions();
    const promises = [];
    for ( const definitions of allFonts ) {
      for ( const [family, definition] of Object.entries(definitions) ) {
        promises.push(this.loadFont(family, definition));
      }
    }
    const timeout = new Promise(resolve => setTimeout(resolve, ms));
    const ready = Promise.all(promises).then(() => document.fonts.ready);
    return Promise.race([ready, timeout]).then(() => console.log(`${vtt} | Fonts loaded and ready.`));
  }

  /* -------------------------------------------- */

  /**
   * Collect all the font definitions and combine them.
   * @returns {Record<string, FontFamilyDefinition>[]}
   * @protected
   */
  static _collectDefinitions() {
    return [CONFIG.fontDefinitions, game.settings.get("core", this.SETTING)];
  }

  /* -------------------------------------------- */

  /**
   * Create FontFace object from a FontDefinition.
   * @param {string} family        The font family name.
   * @param {FontDefinition} font  The font definition.
   * @returns {FontFace}
   * @protected
   */
  static _createFontFace(family, font) {
    const urls = font.urls.map(url => `url("${url}")`).join(", ");
    return new FontFace(family, urls, font);
  }

  /* -------------------------------------------- */

  /**
   * Format a font definition for display.
   * @param {string} family              The font family.
   * @param {FontDefinition} definition  The font definition.
   * @returns {string}                   The formatted definition.
   * @private
   */
  static _formatFont(family, definition) {
    if ( foundry.utils.isEmpty(definition) ) return family;
    const {weight, style} = definition;
    const byWeight = Object.fromEntries(Object.entries(CONST.FONT_WEIGHTS).map(([k, v]) => [v, k]));
    return `
      ${family},
      <span style="font-weight: ${weight}">${byWeight[weight]} ${weight}</span>,
      <span style="font-style: ${style}">${style.toLowerCase()}</span>
    `;
  }
}

/**
 * A tool for fine-tuning the grid in a Scene
 * @param {Scene} scene                       The scene whose grid is being configured.
 * @param {SceneConfig} sheet                 The Scene Configuration sheet that spawned this dialog.
 * @param {FormApplicationOptions} [options]  Application configuration options.
 */
class GridConfig extends FormApplication {
  constructor(scene, sheet, ...args) {
    super(scene, ...args);

    /**
     * Track the Scene Configuration sheet reference
     * @type {SceneConfig}
     */
    this.sheet = sheet;
  }

  /**
   * A reference to the bound key handler function
   * @type {Function}
   */
  #keyHandler;

  /**
   * A reference to the bound mousewheel handler function
   * @type {Function}
   */
  #wheelHandler;

  /**
   * The preview scene
   * @type {Scene}
   */
  #scene = null;

  /**
   * The container containing the preview background image and grid
   * @type {PIXI.Container|null}
   */
  #preview = null;

  /**
   * The background preview
   * @type {PIXI.Sprite|null}
   */
  #background = null;

  /**
   * The grid preview
   * @type {GridMesh|null}
   */
  #grid = null;

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "grid-config",
      template: "templates/scene/grid-config.html",
      title: game.i18n.localize("SCENES.GridConfigTool"),
      width: 480,
      height: "auto",
      closeOnSubmit: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _render(force, options) {
    const states = Application.RENDER_STATES;
    if ( force && [states.CLOSED, states.NONE].includes(this._state) ) {
      if ( !this.object.background.src ) {
        ui.notifications.warn("WARNING.GridConfigNoBG", {localize: true});
      }
      this.#scene = this.object.clone();
    }
    await super._render(force, options);
    await this.#createPreview();
  }


  /* -------------------------------------------- */

  /** @override */
  getData(options={}) {
    const bg = getTexture(this.#scene.background.src);
    return {
      gridTypes: SceneConfig._getGridTypes(),
      scale: this.#scene.background.src ? this.object.width / bg.width : 1,
      scene: this.#scene
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getSubmitData(updateData) {
    const formData = super._getSubmitData(updateData);
    const bg = getTexture(this.#scene.background.src);
    const tex = bg ? bg : {width: this.object.width, height: this.object.height};
    formData.width = tex.width * formData.scale;
    formData.height = tex.height * formData.scale;
    delete formData.scale;
    return formData;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async close(options={}) {
    document.removeEventListener("keydown", this.#keyHandler);
    document.removeEventListener("wheel", this.#wheelHandler);
    this.#keyHandler = this.#wheelHandler = undefined;
    await this.sheet.maximize();

    const states = Application.RENDER_STATES;
    if ( options.force || [states.RENDERED, states.ERROR].includes(this._state) ) {
      this.#scene = null;
      this.#destroyPreview();
    }

    return super.close(options);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    this.#keyHandler ||= this.#onKeyDown.bind(this);
    document.addEventListener("keydown", this.#keyHandler);
    this.#wheelHandler ||= this.#onWheel.bind(this);
    document.addEventListener("wheel", this.#wheelHandler, {passive: false});
    html.find('button[name="reset"]').click(this.#onReset.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle keyboard events.
   * @param {KeyboardEvent} event    The original keydown event
   */
  #onKeyDown(event) {
    const key = event.code;
    const up = ["KeyW", "ArrowUp"];
    const down = ["KeyS", "ArrowDown"];
    const left = ["KeyA", "ArrowLeft"];
    const right = ["KeyD", "ArrowRight"];
    const moveKeys = up.concat(down).concat(left).concat(right);
    if ( !moveKeys.includes(key) ) return;

    // Increase the Scene scale on shift + up or down
    if ( event.shiftKey ) {
      event.preventDefault();
      event.stopPropagation();
      const delta = up.includes(key) ? 1 : (down.includes(key) ? -1 : 0);
      this.#scaleBackgroundSize(delta);
    }

    // Resize grid size on ALT
    else if ( event.altKey ) {
      event.preventDefault();
      event.stopPropagation();
      const delta = up.includes(key) ? 1 : (down.includes(key) ? -1 : 0);
      this.#scaleGridSize(delta);
    }

    // Shift grid position
    else if ( !game.keyboard.hasFocus ) {
      event.preventDefault();
      event.stopPropagation();
      if ( up.includes(key) ) this.#shiftBackground({deltaY: -1});
      else if ( down.includes(key) ) this.#shiftBackground({deltaY: 1});
      else if ( left.includes(key) ) this.#shiftBackground({deltaX: -1});
      else if ( right.includes(key) ) this.#shiftBackground({deltaX: 1});
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle mousewheel events.
   * @param {WheelEvent} event    The original wheel event
   */
  #onWheel(event) {
    if ( event.deltaY === 0 ) return;
    const normalizedDelta = -Math.sign(event.deltaY);
    const activeElement = document.activeElement;
    const noShiftAndAlt = !(event.shiftKey || event.altKey);
    const focus = game.keyboard.hasFocus && document.hasFocus;

    // Increase/Decrease the Scene scale
    if ( event.shiftKey || (!event.altKey && focus && activeElement.name === "scale") ) {
      event.preventDefault();
      event.stopImmediatePropagation();
      this.#scaleBackgroundSize(normalizedDelta);
    }

    // Increase/Decrease the Grid scale
    else if ( event.altKey || (focus && activeElement.name === "grid.size") ) {
      event.preventDefault();
      event.stopImmediatePropagation();
      this.#scaleGridSize(normalizedDelta);
    }

    // If no shift or alt key are pressed
    else if ( noShiftAndAlt && focus ) {
      // Increase/Decrease the background x offset
      if ( activeElement.name === "background.offsetX" ) {
        event.preventDefault();
        event.stopImmediatePropagation();
        this.#shiftBackground({deltaX: normalizedDelta});
      }
      // Increase/Decrease the background y offset
      else if ( activeElement.name === "background.offsetY" ) {
        event.preventDefault();
        event.stopImmediatePropagation();
        this.#shiftBackground({deltaY: normalizedDelta});
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle reset.
   */
  #onReset() {
    if ( !this.#scene ) return;
    this.#scene = this.object.clone();
    this.render();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onChangeInput(event) {
    await super._onChangeInput(event);
    const previewData = this._getSubmitData();
    this.#previewChanges(previewData);
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    const changes = foundry.utils.flattenObject(
      foundry.utils.diffObject(this.object.toObject(), foundry.utils.expandObject(formData)));
    if ( ["width", "height", "padding", "background.offsetX", "background.offsetY", "grid.size", "grid.type"].some(k => k in changes) ) {
      const confirm = await Dialog.confirm({
        title: game.i18n.localize("SCENES.DimensionChangeTitle"),
        content: `<p>${game.i18n.localize("SCENES.DimensionChangeWarning")}</p>`
      });
      // Update only if the dialog is confirmed
      if ( confirm ) return this.object.update(formData, {fromSheet: true});
    }
  }

  /* -------------------------------------------- */
  /*  Previewing and Updating Functions           */
  /* -------------------------------------------- */

  /**
   * Create preview
   */
  async #createPreview() {
    if ( !this.#scene ) return;
    if ( this.#preview ) this.#destroyPreview();
    this.#preview = canvas.stage.addChild(new PIXI.Container());
    this.#preview.eventMode = "none";
    const fill = this.#preview.addChild(new PIXI.Sprite(PIXI.Texture.WHITE));
    fill.tint = 0x000000;
    fill.eventMode = "static";
    fill.hitArea = canvas.app.screen;
    // Patching updateTransform to render the fill in screen space
    fill.updateTransform = function() {
      const screen = canvas.app.screen;
      this.width = screen.width;
      this.height = screen.height;
      this._boundsID++;
      this.transform.updateTransform(PIXI.Transform.IDENTITY);
      this.worldAlpha = this.alpha;
    };
    this.#background = this.#preview.addChild(new PIXI.Sprite());
    this.#background.eventMode = "none";
    if ( this.#scene.background.src ) {
      try {
        this.#background.texture = await loadTexture(this.#scene.background.src);
      } catch(e) {
        this.#background.texture = PIXI.Texture.WHITE;
        console.error(e);
      }
    } else {
      this.#background.texture = PIXI.Texture.WHITE;
    }
    this.#grid = this.#preview.addChild(new GridMesh().initialize({color: 0xFF0000}));
    this.#refreshPreview();
  }

  /* -------------------------------------------- */

  /**
   * Preview changes to the Scene document as if they were true document updates.
   * @param {object} [change]  A change to preview.
   */
  #previewChanges(change) {
    if ( !this.#scene ) return;
    if ( change ) this.#scene.updateSource(change);
    this.#refreshPreview();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the preview
   */
  #refreshPreview() {
    if ( !this.#scene || (this.#preview?.destroyed !== false) ) return;

    // Update the background image
    const d = this.#scene.dimensions;
    this.#background.position.set(d.sceneX, d.sceneY);
    this.#background.width = d.sceneWidth;
    this.#background.height = d.sceneHeight;

    // Update the grid
    this.#grid.initialize({
      type: this.#scene.grid.type,
      width: d.width,
      height: d.height,
      size: d.size
    });
  }

  /* -------------------------------------------- */

  /**
   * Destroy the preview
   */
  #destroyPreview() {
    if ( this.#preview?.destroyed === false ) this.#preview.destroy({children: true});
    this.#preview = null;
    this.#background = null;
    this.#grid = null;
  }

  /* -------------------------------------------- */

  /**
   * Scale the background size relative to the grid size
   * @param {number} delta          The directional change in background size
   */
  #scaleBackgroundSize(delta) {
    const scale = (parseFloat(this.form.scale.value) + (delta * 0.001)).toNearest(0.001);
    this.form.scale.value = Math.clamp(scale, 0.25, 10.0);
    this.form.scale.dispatchEvent(new Event("change", {bubbles: true}));
  }

  /* -------------------------------------------- */

  /**
   * Scale the grid size relative to the background image.
   * When scaling the grid size in this way, constrain the allowed values between 50px and 300px.
   * @param {number} delta          The grid size in pixels
   */
  #scaleGridSize(delta) {
    const gridSize = this.form.elements["grid.size"];
    gridSize.value = Math.clamp(gridSize.valueAsNumber + delta, 50, 300);
    gridSize.dispatchEvent(new Event("change", {bubbles: true}));
  }

  /* -------------------------------------------- */

  /**
   * Shift the background image relative to the grid layer
   * @param {object} position               The position configuration to preview
   * @param {number} [position.deltaX=0]    The number of pixels to shift in the x-direction
   * @param {number} [position.deltaY=0]    The number of pixels to shift in the y-direction
   */
  #shiftBackground({deltaX=0, deltaY=0}) {
    const ox = this.form["background.offsetX"];
    ox.value = parseInt(this.form["background.offsetX"].value) + deltaX;
    this.form["background.offsetY"].value = parseInt(this.form["background.offsetY"].value) + deltaY;
    ox.dispatchEvent(new Event("change", {bubbles: true}));
  }
}

/**
 * @typedef {FormApplicationOptions} ImagePopoutOptions
 * @property {string} [caption]           Caption text to display below the image.
 * @property {string|null} [uuid=null]    The UUID of some related {@link Document}.
 * @property {boolean} [showTitle]        Force showing or hiding the title.
 */

/**
 * An Image Popout Application which features a single image in a lightbox style frame.
 * Furthermore, this application allows for sharing the display of an image with other connected players.
 * @param {string} src                    The image URL.
 * @param {ImagePopoutOptions} [options]  Application configuration options.
 *
 * @example Creating an Image Popout
 * ```js
 * // Construct the Application instance
 * const ip = new ImagePopout("path/to/image.jpg", {
 *   title: "My Featured Image",
 *   uuid: game.actors.getName("My Hero").uuid
 * });
 *
 * // Display the image popout
 * ip.render(true);
 *
 * // Share the image with other connected players
 * ip.share();
 * ```
 */
class ImagePopout extends FormApplication {
  /**
   * A cached reference to the related Document.
   * @type {ClientDocument}
   */
  #related;

  /* -------------------------------------------- */

  /**
   * Whether the application should display video content.
   * @type {boolean}
   */
  get isVideo() {
    return VideoHelper.hasVideoExtension(this.object);
  }

  /* -------------------------------------------- */

  /**
   * @override
   * @returns {ImagePopoutOptions}
   */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "templates/apps/image-popout.html",
      classes: ["image-popout", "dark"],
      resizable: true,
      caption: undefined,
      uuid: null
    });
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return this.isTitleVisible() ? super.title : "";
  }

  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    return {
      image: this.object,
      options: this.options,
      title: this.title,
      caption: this.options.caption,
      showTitle: this.isTitleVisible(),
      isVideo: this.isVideo
    };
  }

  /* -------------------------------------------- */

  /**
   * Test whether the title of the image popout should be visible to the user
   * @returns {boolean}
   */
  isTitleVisible() {
    return this.options.showTitle ?? this.#related?.testUserPermission(game.user, "LIMITED") ?? true;
  }

  /* -------------------------------------------- */

  /**
   * Provide a reference to the Document referenced by this popout, if one exists
   * @returns {Promise<ClientDocument>}
   */
  async getRelatedObject() {
    if ( this.options.uuid && !this.#related ) this.#related = await fromUuid(this.options.uuid);
    return this.#related;
  }

  /* -------------------------------------------- */

  /** @override */
  async _render(...args) {
    await this.getRelatedObject();
    this.position = await this.constructor.getPosition(this.object);
    return super._render(...args);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    // For some reason, unless we do this, videos will not autoplay the first time the popup is opened in a session,
    // even if the user has made a gesture.
    if ( this.isVideo ) html.find("video")[0]?.play();
  }

  /* -------------------------------------------- */

  /** @override */
  _getHeaderButtons() {
    const buttons = super._getHeaderButtons();
    if ( game.user.isGM ) {
      buttons.unshift({
        label: "JOURNAL.ActionShow",
        class: "share-image",
        icon: "fas fa-eye",
        onclick: () => this.shareImage()
      });
    }
    return buttons;
  }

  /* -------------------------------------------- */
  /*  Helper Methods
  /* -------------------------------------------- */

  /**
   * Determine the correct position and dimensions for the displayed image
   * @param {string} img  The image URL.
   * @returns {Object}    The positioning object which should be used for rendering
   */
  static async getPosition(img) {
    if ( !img ) return { width: 480, height: 480 };
    let w;
    let h;
    try {
      [w, h] = this.isVideo ? await this.getVideoSize(img) : await this.getImageSize(img);
    } catch(err) {
      return { width: 480, height: 480 };
    }
    const position = {};

    // Compare the image aspect ratio to the screen aspect ratio
    const sr = window.innerWidth / window.innerHeight;
    const ar = w / h;

    // The image is constrained by the screen width, display at max width
    if ( ar > sr ) {
      position.width = Math.min(w * 2, window.innerWidth - 80);
      position.height = position.width / ar;
    }

    // The image is constrained by the screen height, display at max height
    else {
      position.height = Math.min(h * 2, window.innerHeight - 120);
      position.width = position.height * ar;
    }
    return position;
  }

  /* -------------------------------------------- */

  /**
   * Determine the Image dimensions given a certain path
   * @param {string} path  The image source.
   * @returns {Promise<[number, number]>}
   */
  static getImageSize(path) {
    return new Promise((resolve, reject) => {
      const img = new Image();
      img.onload = function() {
        resolve([this.width, this.height]);
      };
      img.onerror = reject;
      img.src = path;
    });
  }

  /* -------------------------------------------- */

  /**
   * Determine the dimensions of the given video file.
   * @param {string} src  The URL to the video.
   * @returns {Promise<[number, number]>}
   */
  static getVideoSize(src) {
    return new Promise((resolve, reject) => {
      const video = document.createElement("video");
      video.onloadedmetadata = () => {
        video.onloadedmetadata = null;
        resolve([video.videoWidth, video.videoHeight]);
      };
      video.onerror = reject;
      video.src = src;
    });
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} ShareImageConfig
   * @property {string} image         The image URL to share.
   * @property {string} title         The image title.
   * @property {string} [uuid]        The UUID of a Document related to the image, used to determine permission to see
   *                                  the image title.
   * @property {boolean} [showTitle]  If this is provided, the permissions of the related Document will be ignored and
   *                                  the title will be shown based on this parameter.
   * @property {string[]} [users]     A list of user IDs to show the image to.
   */

  /**
   * Share the displayed image with other connected Users
   * @param {ShareImageConfig} [options]
   */
  shareImage(options={}) {
    options = foundry.utils.mergeObject(this.options, options, { inplace: false });
    game.socket.emit("shareImage", {
      image: this.object,
      title: options.title,
      caption: options.caption,
      uuid: options.uuid,
      showTitle: options.showTitle,
      users: Array.isArray(options.users) ? options.users : undefined
    });
    ui.notifications.info(game.i18n.format("JOURNAL.ActionShowSuccess", {
      mode: "image",
      title: options.title,
      which: "all"
    }));
  }

  /* -------------------------------------------- */

  /**
   * Handle a received request to display an image.
   * @param {ShareImageConfig} config  The image configuration data.
   * @returns {ImagePopout}
   * @internal
   */
  static _handleShareImage({image, title, caption, uuid, showTitle}={}) {
    const ip = new ImagePopout(image, {title, caption, uuid, showTitle});
    ip.render(true);
    return ip;
  }
}

/**
 * The Application responsible for displaying and editing a single Item document.
 * @param {Item} item                       The Item instance being displayed within the sheet.
 * @param {DocumentSheetOptions} [options]  Additional application configuration options.
 */
class ItemSheet extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "templates/sheets/item-sheet.html",
      width: 500,
      closeOnSubmit: false,
      submitOnClose: true,
      submitOnChange: true,
      resizable: true,
      baseApplication: "ItemSheet",
      id: "item",
      secrets: [{parentSelector: ".editor"}]
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get title() {
    return this.item.name;
  }

  /* -------------------------------------------- */

  /**
   * A convenience reference to the Item document
   * @type {Item}
   */
  get item() {
    return this.object;
  }

  /* -------------------------------------------- */

  /**
   * The Actor instance which owns this item. This may be null if the item is unowned.
   * @type {Actor}
   */
  get actor() {
    return this.item.actor;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    const data = super.getData(options);
    data.item = data.document;
    return data;
  }
}

/**
 * The Application responsible for displaying and editing a single JournalEntryPage document.
 * @extends {DocumentSheet}
 * @param {JournalEntryPage} object         The JournalEntryPage instance which is being edited.
 * @param {DocumentSheetOptions} [options]  Application options.
 */
class JournalPageSheet extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["sheet", "journal-sheet", "journal-entry-page"],
      viewClasses: [],
      width: 600,
      height: 680,
      resizable: true,
      closeOnSubmit: false,
      submitOnClose: true,
      viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
      includeTOC: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get template() {
    return `templates/journal/page-${this.document.type}-${this.isEditable ? "edit" : "view"}.html`;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get title() {
    return this.object.permission ? this.object.name : "";
  }

  /* -------------------------------------------- */

  /**
   * The table of contents for this JournalTextPageSheet.
   * @type {Record<string, JournalEntryPageHeading>}
   */
  toc = {};

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    return foundry.utils.mergeObject(super.getData(options), {
      headingLevels: Object.fromEntries(Array.fromRange(3, 1).map(level => {
        return [level, game.i18n.format("JOURNALENTRYPAGE.Level", {level})];
      }))
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _renderInner(...args) {
    await loadTemplates({
      journalEntryPageHeader: "templates/journal/parts/page-header.html",
      journalEntryPageFooter: "templates/journal/parts/page-footer.html"
    });
    const html = await super._renderInner(...args);
    if ( this.options.includeTOC ) this.toc = JournalEntryPage.implementation.buildTOC(html.get());
    return html;
  }

  /* -------------------------------------------- */

  /**
   * A method called by the journal sheet when the view mode of the page sheet is closed.
   * @internal
   */
  _closeView() {}

  /* -------------------------------------------- */
  /*  Text Secrets Management                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _getSecretContent(secret) {
    return this.object.text.content;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _updateSecret(secret, content) {
    return this.object.update({"text.content": content});
  }

  /* -------------------------------------------- */
  /*  Text Editor Integration                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  async activateEditor(name, options={}, initialContent="") {
    options.fitToSize = true;
    options.relativeLinks = true;
    const editor = await super.activateEditor(name, options, initialContent);
    this.form.querySelector('[role="application"]')?.style.removeProperty("height");
    return editor;
  }

  /* -------------------------------------------- */

  /**
   * Update the parent sheet if it is open when the server autosaves the contents of this editor.
   * @param {string} html  The updated editor contents.
   */
  onAutosave(html) {
    this.object.parent?.sheet?.render(false);
  }

  /* -------------------------------------------- */

  /**
   * Update the UI appropriately when receiving new steps from another client.
   */
  onNewSteps() {
    this.form.querySelectorAll('[data-action="save-html"]').forEach(el => el.disabled = true);
  }
}

/**
 * The Application responsible for displaying and editing a single JournalEntryPage text document.
 * @extends {JournalPageSheet}
 */
class JournalTextPageSheet extends JournalPageSheet {
  /**
   * Bi-directional HTML <-> Markdown converter.
   * @type {showdown.Converter}
   * @protected
   */
  static _converter = (() => {
    Object.entries(CONST.SHOWDOWN_OPTIONS).forEach(([k, v]) => showdown.setOption(k, v));
    return new showdown.Converter();
  })();

  /* -------------------------------------------- */

  /**
   * Declare the format that we edit text content in for this sheet so we can perform conversions as necessary.
   * @type {number}
   */
  static get format() {
    return CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.classes.push("text");
    options.secrets.push({parentSelector: "section.journal-page-content"});
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async getData(options={}) {
    const data = super.getData(options);
    this._convertFormats(data);
    data.editor = {
      engine: "prosemirror",
      collaborate: true,
      content: await TextEditor.enrichHTML(data.document.text.content, {
        relativeTo: this.object,
        secrets: this.object.isOwner
      })
    };
    return data;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async close(options={}) {
    Object.values(this.editors).forEach(ed => {
      if ( ed.instance ) ed.instance.destroy();
    });
    return super.close(options);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _render(force, options) {
    if ( !this.#canRender(options.resync) ) return this.maximize().then(() => this.bringToTop());
    return super._render(force, options);
  }

  /* -------------------------------------------- */

  /**
   * Suppress re-rendering the sheet in cases where an active editor has unsaved work.
   * In such cases we rely upon collaborative editing to save changes and re-render.
   * @param {boolean} [resync]    Was the application instructed to re-sync?
   * @returns {boolean}           Should a render operation be allowed?
   */
  #canRender(resync) {
    if ( resync || (this._state !== Application.RENDER_STATES.RENDERED) || !this.isEditable ) return true;
    return !this.isEditorDirty();
  }

  /* -------------------------------------------- */

  /**
   * Determine if any editors are dirty.
   * @returns {boolean}
   */
  isEditorDirty() {
    for ( const editor of Object.values(this.editors) ) {
      if ( editor.active && editor.instance?.isDirty() ) return true;
    }
    return false;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {
    if ( (this.constructor.format === CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML) && this.isEditorDirty() ) {
      // Clear any stored markdown so it can be re-converted.
      formData["text.markdown"] = "";
      formData["text.format"] = CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
    }
    return super._updateObject(event, formData);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async saveEditor(name, { preventRender=true, ...options }={}) {
    return super.saveEditor(name, { ...options, preventRender });
  }

  /* -------------------------------------------- */

  /**
   * Lazily convert text formats if we detect the document being saved in a different format.
   * @param {object} renderData  Render data.
   * @protected
   */
  _convertFormats(renderData) {
    const formats = CONST.JOURNAL_ENTRY_PAGE_FORMATS;
    const text = this.object.text;
    if ( (this.constructor.format === formats.MARKDOWN) && text.content?.length && !text.markdown?.length ) {
      // We've opened an HTML document in a markdown editor, so we need to convert the HTML to markdown for editing.
      renderData.data.text.markdown = this.constructor._converter.makeMarkdown(text.content.trim());
    }
  }
}

/* -------------------------------------------- */

/**
 * The Application responsible for displaying and editing a single JournalEntryPage image document.
 * @extends {JournalPageSheet}
 */
class JournalImagePageSheet extends JournalPageSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.classes.push("image");
    options.height = "auto";
    return options;
  }
}

/* -------------------------------------------- */

/**
 * The Application responsible for displaying and editing a single JournalEntryPage video document.
 * @extends {JournalPageSheet}
 */
class JournalVideoPageSheet extends JournalPageSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.classes.push("video");
    options.height = "auto";
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    return foundry.utils.mergeObject(super.getData(options), {
      flexRatio: !this.object.video.width && !this.object.video.height,
      isYouTube: game.video.isYouTubeURL(this.object.src),
      timestamp: this._timestampToTimeComponents(this.object.video.timestamp),
      yt: {
        id: `youtube-${foundry.utils.randomID()}`,
        url: game.video.getYouTubeEmbedURL(this.object.src, this._getYouTubeVars())
      }
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    if ( this.isEditable ) return;
    // The below listeners are only for when the video page is being viewed, not edited.
    const iframe = html.find("iframe")[0];
    if ( iframe ) game.video.getYouTubePlayer(iframe.id, {
      events: {
        onStateChange: event => {
          if ( event.data === YT.PlayerState.PLAYING ) event.target.setVolume(this.object.video.volume * 100);
        }
      }
    }).then(player => {
      if ( this.object.video.timestamp ) player.seekTo(this.object.video.timestamp, true);
    });
    const video = html.parent().find("video")[0];
    if ( video ) {
      video.addEventListener("loadedmetadata", () => {
        video.volume = this.object.video.volume;
        if ( this.object.video.timestamp ) video.currentTime = this.object.video.timestamp;
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the YouTube player parameters depending on whether the sheet is being viewed or edited.
   * @returns {object}
   * @protected
   */
  _getYouTubeVars() {
    const vars = {playsinline: 1, modestbranding: 1};
    if ( !this.isEditable ) {
      vars.controls = this.object.video.controls ? 1 : 0;
      vars.autoplay = this.object.video.autoplay ? 1 : 0;
      vars.loop = this.object.video.loop ? 1 : 0;
      if ( this.object.video.timestamp ) vars.start = this.object.video.timestamp;
    }
    return vars;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getSubmitData(updateData={}) {
    const data = super._getSubmitData(updateData);
    data["video.timestamp"] = this._timeComponentsToTimestamp(foundry.utils.expandObject(data).timestamp);
    ["h", "m", "s"].forEach(c => delete data[`timestamp.${c}`]);
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Convert time components to a timestamp in seconds.
   * @param {{[h]: number, [m]: number, [s]: number}} components  The time components.
   * @returns {number}                                            The timestamp, in seconds.
   * @protected
   */
  _timeComponentsToTimestamp({h=0, m=0, s=0}={}) {
    return (h * 3600) + (m * 60) + s;
  }

  /* -------------------------------------------- */

  /**
   * Convert a timestamp in seconds into separate time components.
   * @param {number} timestamp                           The timestamp, in seconds.
   * @returns {{[h]: number, [m]: number, [s]: number}}  The individual time components.
   * @protected
   */
  _timestampToTimeComponents(timestamp) {
    if ( !timestamp ) return {};
    const components = {};
    const h = Math.floor(timestamp / 3600);
    if ( h ) components.h = h;
    const m = Math.floor((timestamp % 3600) / 60);
    if ( m ) components.m = m;
    components.s = timestamp - (h * 3600) - (m * 60);
    return components;
  }
}

/* -------------------------------------------- */

/**
 * The Application responsible for displaying and editing a single JournalEntryPage PDF document.
 * @extends {JournalPageSheet}
 */
class JournalPDFPageSheet extends JournalPageSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.classes.push("pdf");
    options.height = "auto";
    return options;
  }

  /**
   * Maintain a cache of PDF sizes to avoid making HEAD requests every render.
   * @type {Record<string, number>}
   * @protected
   */
  static _sizes = {};

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("> button").on("click", this._onLoadPDF.bind(this));
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    return foundry.utils.mergeObject(super.getData(options), {
      params: this._getViewerParams()
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _renderInner(...args) {
    const html = await super._renderInner(...args);
    const pdfLoader = html.closest(".load-pdf")[0];
    if ( this.isEditable || !pdfLoader ) return html;
    let size = this.constructor._sizes[this.object.src];
    if ( size === undefined ) {
      const res = await fetch(this.object.src, {method: "HEAD"}).catch(() => {});
      this.constructor._sizes[this.object.src] = size = Number(res?.headers.get("content-length"));
    }
    if ( !isNaN(size) ) {
      const mb = (size / 1024 / 1024).toFixed(2);
      const span = document.createElement("span");
      span.classList.add("hint");
      span.textContent = ` (${mb} MB)`;
      pdfLoader.querySelector("button").appendChild(span);
    }
    return html;
  }

  /* -------------------------------------------- */

  /**
   * Handle a request to load a PDF.
   * @param {MouseEvent} event  The triggering event.
   * @protected
   */
  _onLoadPDF(event) {
    const target = event.currentTarget.parentElement;
    const frame = document.createElement("iframe");
    frame.src = `scripts/pdfjs/web/viewer.html?${this._getViewerParams()}`;
    target.replaceWith(frame);
  }

  /* -------------------------------------------- */

  /**
   * Retrieve parameters to pass to the PDF viewer.
   * @returns {URLSearchParams}
   * @protected
   */
  _getViewerParams() {
    const params = new URLSearchParams();
    if ( this.object.src ) {
      const src = URL.parseSafe(this.object.src) ? this.object.src : foundry.utils.getRoute(this.object.src);
      params.append("file", src);
    }
    return params;
  }
}

/**
 * A subclass of {@link JournalTextPageSheet} that implements a markdown editor for editing the text content.
 * @extends {JournalTextPageSheet}
 */
class MarkdownJournalPageSheet extends JournalTextPageSheet {
  /**
   * Store the dirty flag for this editor.
   * @type {boolean}
   * @protected
   */
  _isDirty = false;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get format() {
    return CONST.JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.dragDrop = [{dropSelector: "textarea"}];
    options.classes.push("markdown");
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get template() {
    if ( this.isEditable ) return "templates/journal/page-markdown-edit.html";
    return super.template;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async getData(options={}) {
    const data = await super.getData(options);
    data.markdownFormat = CONST.JOURNAL_ENTRY_PAGE_FORMATS.MARKDOWN;
    return data;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("textarea").on("keypress paste", () => this._isDirty = true);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  isEditorDirty() {
    return this._isDirty;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {
    // Do not persist the markdown conversion if the contents have not been edited.
    if ( !this.isEditorDirty() ) {
      delete formData["text.markdown"];
      delete formData["text.format"];
    }
    return super._updateObject(event, formData);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDrop(event) {
    event.preventDefault();
    const eventData = TextEditor.getDragEventData(event);
    return this._onDropContentLink(eventData);
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping a content link onto the editor.
   * @param {object} eventData  The parsed event data.
   * @protected
   */
  async _onDropContentLink(eventData) {
    const link = await TextEditor.getContentLink(eventData, {relativeTo: this.object});
    if ( !link ) return;
    const editor = this.form.elements["text.markdown"];
    const content = editor.value;
    editor.value = content.substring(0, editor.selectionStart) + link + content.substring(editor.selectionStart);
    this._isDirty = true;
  }
}

/**
 * A subclass of {@link JournalTextPageSheet} that implements a TinyMCE editor.
 * @extends {JournalTextPageSheet}
 */
class JournalTextTinyMCESheet extends JournalTextPageSheet {
  /** @inheritdoc */
  async getData(options={}) {
    const data = await super.getData(options);
    data.editor.engine = "tinymce";
    data.editor.collaborate = false;
    return data;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async close(options = {}) {
    return JournalPageSheet.prototype.close.call(this, options);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _render(force, options) {
    return JournalPageSheet.prototype._render.call(this, force, options);
  }
}

/**
 * @typedef {DocumentSheetOptions} JournalSheetOptions
 * @property {string|null} [sheetMode]  The current display mode of the journal. Either 'text' or 'image'.
 */

/**
 * The Application responsible for displaying and editing a single JournalEntry document.
 * @extends {DocumentSheet}
 * @param {JournalEntry} object            The JournalEntry instance which is being edited
 * @param {JournalSheetOptions} [options]  Application options
 */
class JournalSheet extends DocumentSheet {

  /**
   * @override
   * @returns {JournalSheetOptions}
   */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["sheet", "journal-sheet", "journal-entry"],
      template: "templates/journal/sheet.html",
      width: 960,
      height: 800,
      resizable: true,
      submitOnChange: true,
      submitOnClose: true,
      closeOnSubmit: false,
      viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE,
      scrollY: [".scrollable"],
      filters: [{inputSelector: 'input[name="search"]', contentSelector: ".directory-list"}],
      dragDrop: [{dragSelector: ".directory-item, .heading-link", dropSelector: ".directory-list"}],
      pageIndex: undefined,
      pageId: undefined
    });
  }

  /* -------------------------------------------- */

  /**
   * The cached list of processed page entries.
   * This array is populated in the getData method.
   * @type {object[]}
   * @protected
   */
  _pages;

  /**
   * Track which page IDs are currently displayed due to a search filter
   * @type {Set<string>}
   * @private
   */
  #filteredPages = new Set();

  /**
   * The pages that are currently scrolled into view and marked as 'active' in the sidebar.
   * @type {HTMLElement[]}
   * @private
   */
  #pagesInView = [];

  /**
   * The index of the currently viewed page.
   * @type {number}
   * @private
   */
  #pageIndex = 0;

  /**
   * Has the player been granted temporary ownership of this journal entry or its pages?
   * @type {boolean}
   * @private
   */
  #tempOwnership = false;

  /**
   * A mapping of page IDs to {@link JournalPageSheet} instances used for rendering the pages inside the journal entry.
   * @type {Record<string, JournalPageSheet>}
   */
  #sheets = {};

  /**
   * Store a flag to restore ToC positions after a render.
   * @type {boolean}
   */
  #restoreTOCPositions = false;

  /**
   * Store transient sidebar state so it can be restored after context menus are closed.
   * @type {{position: number, active: boolean, collapsed: boolean}}
   */
  #sidebarState = {collapsed: false};

  /**
   * Store a reference to the currently active IntersectionObserver.
   * @type {IntersectionObserver}
   */
  #observer;

  /**
   * Store a special set of heading intersections so that we can quickly compute the top-most heading in the viewport.
   * @type {Map<HTMLHeadingElement, IntersectionObserverEntry>}
   */
  #headingIntersections = new Map();

  /**
   * Store the journal entry's current view mode.
   * @type {number|null}
   */
  #mode = null;

  /* -------------------------------------------- */

  /**
   * Get the journal entry's current view mode.
   * @see {@link JournalSheet.VIEW_MODES}
   * @returns {number}
   */
  get mode() {
    return this.#mode ?? this.document.getFlag("core", "viewMode") ?? this.constructor.VIEW_MODES.SINGLE;
  }

  /* -------------------------------------------- */

  /**
   * The current search mode for this journal
   * @type {string}
   */
  get searchMode() {
    return this.document.getFlag("core", "searchMode") || CONST.DIRECTORY_SEARCH_MODES.NAME;
  }

  /**
   * Toggle the search mode for this journal between "name" and "full" text search
   */
  toggleSearchMode() {
    const updatedSearchMode = this.document.getFlag("core", "searchMode") === CONST.DIRECTORY_SEARCH_MODES.NAME ?
      CONST.DIRECTORY_SEARCH_MODES.FULL : CONST.DIRECTORY_SEARCH_MODES.NAME;
    this.document.setFlag("core", "searchMode", updatedSearchMode);
  }

  /* -------------------------------------------- */

  /**
   * The pages that are currently scrolled into view and marked as 'active' in the sidebar.
   * @type {HTMLElement[]}
   */
  get pagesInView() {
    return this.#pagesInView;
  }

  /* -------------------------------------------- */

  /**
   * The index of the currently viewed page.
   * @type {number}
   */
  get pageIndex() {
    return this.#pageIndex;
  }

  /* -------------------------------------------- */

  /**
   * The currently active IntersectionObserver.
   * @type {IntersectionObserver}
   */
  get observer() {
    return this.#observer;
  }

  /* -------------------------------------------- */

  /**
   * Is the table-of-contents sidebar currently collapsed?
   * @type {boolean}
   */
  get sidebarCollapsed() {
    return this.#sidebarState.collapsed;
  }

  /* -------------------------------------------- */

  /**
   * Available view modes for journal entries.
   * @enum {number}
   */
  static VIEW_MODES = {
    SINGLE: 1,
    MULTIPLE: 2
  };

  /* -------------------------------------------- */

  /**
   * The minimum amount of content that must be visible before the next page is marked as in view. Cannot be less than
   * 25% without also modifying the IntersectionObserver threshold.
   * @type {number}
   */
  static INTERSECTION_RATIO = .25;

  /* -------------------------------------------- */

  /**
   * Icons for page ownership.
   * @enum {string}
   */
  static OWNERSHIP_ICONS = {
    [CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE]: "fa-solid fa-eye-slash",
    [CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER]: "fa-solid fa-eye",
    [CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER]: "fa-solid fa-feather-pointed"
  };

  /* -------------------------------------------- */

  /** @inheritdoc */
  get title() {
    const folder = game.folders.get(this.object.folder?.id);
    const name = `${folder ? `${folder.name}: ` : ""}${this.object.name}`;
    return this.object.permission ? name : "";
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getHeaderButtons() {
    const buttons = super._getHeaderButtons();
    // Share Entry
    if ( game.user.isGM ) {
      buttons.unshift({
        label: "JOURNAL.ActionShow",
        class: "share-image",
        icon: "fas fa-eye",
        onclick: ev => this._onShowPlayers(ev)
      });
    }
    return buttons;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    const context = super.getData(options);
    context.mode = this.mode;
    context.toc = this._pages = this._getPageData();
    this._getCurrentPage(options);
    context.viewMode = {};

    // Viewing single page
    if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) {
      context.pages = [context.toc[this.pageIndex]];
      context.viewMode = {label: "JOURNAL.ViewMultiple", icon: "fa-solid fa-note", cls: "single-page"};
    }

    // Viewing multiple pages
    else {
      context.pages = context.toc;
      context.viewMode = {label: "JOURNAL.ViewSingle", icon: "fa-solid fa-notes", cls: "multi-page"};
    }

    // Sidebar collapsed mode
    context.sidebarClass = this.sidebarCollapsed ? "collapsed" : "";
    context.collapseMode = this.sidebarCollapsed
      ? {label: "JOURNAL.ViewExpand", icon: "fa-solid fa-caret-left"}
      : {label: "JOURNAL.ViewCollapse", icon: "fa-solid fa-caret-right"};

    // Search mode
    context.searchIcon = this.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "fa-search" :
      "fa-file-magnifying-glass";
    context.searchTooltip = this.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME ? "SIDEBAR.SearchModeName" :
      "SIDEBAR.SearchModeFull";
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare pages for display.
   * @returns {JournalEntryPage[]}  The sorted list of pages.
   * @protected
   */
  _getPageData() {
    const hasFilterQuery = !!this._searchFilters[0].query;
    return this.object.pages.contents.sort((a, b) => a.sort - b.sort).reduce((arr, page) => {
      if ( !this.isPageVisible(page) ) return arr;
      const p = page.toObject();
      const sheet = this.getPageSheet(page.id);

      // Page CSS classes
      const cssClasses = [p.type, `level${p.title.level}`];
      if ( hasFilterQuery && !this.#filteredPages.has(page.id) ) cssClasses.push("hidden");
      p.tocClass = p.cssClass = cssClasses.join(" ");
      cssClasses.push(...(sheet.options.viewClasses || []));
      p.viewClass = cssClasses.join(" ");

      // Other page data
      p.editable = page.isOwner;
      if ( page.parent.pack ) p.editable &&= !game.packs.get(page.parent.pack)?.locked;
      p.number = arr.length;
      p.icon = this.constructor.OWNERSHIP_ICONS[page.ownership.default];
      const levels = Object.entries(CONST.DOCUMENT_OWNERSHIP_LEVELS);
      const [ownership] = levels.find(([, level]) => level === page.ownership.default);
      p.ownershipCls = ownership.toLowerCase();
      arr.push(p);
      return arr;
    }, []);
  }

  /* -------------------------------------------- */

  /**
   * Identify which page of the journal sheet should be currently rendered.
   * This can be controlled by options passed into the render method or by a subclass override.
   * @param {object} options    Sheet rendering options
   * @param {number} [options.pageIndex]    A numbered index of page to render
   * @param {string} [options.pageId]       The ID of a page to render
   * @returns {number}      The currently displayed page index
   * @protected
   */
  _getCurrentPage({pageIndex, pageId}={}) {
    let newPageIndex;
    if ( typeof pageIndex === "number" ) newPageIndex = pageIndex;
    if ( pageId ) newPageIndex = this._pages.findIndex(p => p._id === pageId);
    if ( (newPageIndex != null) && (newPageIndex !== this.pageIndex) ) {
      if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) this.#callCloseHooks(this.pageIndex);
      this.#pageIndex = newPageIndex;
    }
    this.options.pageIndex = this.options.pageId = undefined;
    return this.#pageIndex = Math.clamp(this.pageIndex, 0, this._pages.length - 1);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.on("click", "img:not(.nopopout)", this._onClickImage.bind(this));
    html.find("button[data-action], a[data-action]").click(this._onAction.bind(this));
    this._contextMenu(html);
  }

  /* -------------------------------------------- */

  /**
   * Activate listeners after page content has been injected.
   * @protected
   */
  _activatePageListeners() {
    const html = this.element;
    html.find(".editor-edit").click(this._onEditPage.bind(this));
    html.find(".page-heading").click(this._onClickPageLink.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * @inheritdoc
   * @param {number} [options.mode]       Render the sheet in a given view mode, see {@link JournalSheet.VIEW_MODES}.
   * @param {string} [options.pageId]     Render the sheet with the page with the given ID in view.
   * @param {number} [options.pageIndex]  Render the sheet with the page at the given index in view.
   * @param {string} [options.anchor]     Render the sheet with the given anchor for the given page in view.
   * @param {boolean} [options.tempOwnership]  Whether the journal entry or one of its pages is being shown to players
   *                                           who might otherwise not have permission to view it.
   * @param {boolean} [options.collapsed] Render the sheet with the TOC sidebar collapsed?
   */
  async _render(force, options={}) {

    // Temporary override of ownership
    if ( "tempOwnership" in options ) this.#tempOwnership = options.tempOwnership;

    // Override the view mode
    const modeChange = ("mode" in options) && (options.mode !== this.mode);
    if ( modeChange ) {
      if ( this.mode === this.constructor.VIEW_MODES.MULTIPLE ) this.#callCloseHooks();
      this.#mode = options.mode;
    }
    if ( "collapsed" in options ) this.#sidebarState.collapsed = options.collapsed;

    // Render the application
    await super._render(force, options);
    if ( !this.rendered ) return;
    await this._renderPageViews();
    this._activatePageListeners();

    // Re-sync the TOC scroll position to the new view
    const pageChange = ("pageIndex" in options) || ("pageId" in options);
    if ( modeChange || pageChange ) {
      const pageId = this._pages[this.pageIndex]?._id;
      if ( this.mode === this.constructor.VIEW_MODES.MULTIPLE ) this.goToPage(pageId, options.anchor);
      else if ( options.anchor ) {
        this.getPageSheet(pageId)?.toc[options.anchor]?.element?.scrollIntoView();
        this.#restoreTOCPositions = true;
      }
    }
    else this._restoreScrollPositions(this.element);
  }

  /* -------------------------------------------- */

  /**
   * Update child views inside the main sheet.
   * @returns {Promise<void>}
   * @protected
   */
  async _renderPageViews() {
    for ( const pageNode of this.element[0].querySelectorAll(".journal-entry-page") ) {
      const id = pageNode.dataset.pageId;
      if ( !id ) continue;
      const edit = pageNode.querySelector(":scope > .edit-container");
      const sheet = this.getPageSheet(id);
      const data = await sheet.getData();
      const view = await sheet._renderInner(data);
      pageNode.replaceChildren(...view.get());
      if ( edit ) pageNode.appendChild(edit);
      sheet._activateCoreListeners(view.parent());
      sheet.activateListeners(view);
      await this._renderHeadings(pageNode, sheet.toc);
      sheet._callHooks("render", view, data);
    }
    this._observePages();
    this._observeHeadings();
  }

  /* -------------------------------------------- */

  /**
   * Call close hooks for individual pages.
   * @param {number} [pageIndex]  Calls the hook for this page only, otherwise calls for all pages.
   */
  #callCloseHooks(pageIndex) {
    if ( !this._pages?.length || (pageIndex < 0) ) return;
    const pages = pageIndex != null ? [this._pages[pageIndex]] : this._pages;
    for ( const page of pages ) {
      const sheet = this.getPageSheet(page._id);
      sheet._callHooks("close", sheet.element);
      sheet._closeView();
    }
  }

  /* -------------------------------------------- */

  /**
   * Add headings to the table of contents for the given page node.
   * @param {HTMLElement} pageNode                         The HTML node of the page's rendered contents.
   * @param {Record<string, JournalEntryPageHeading>} toc  The page's table of contents.
   * @protected
   */
  async _renderHeadings(pageNode, toc) {
    const pageId = pageNode.dataset.pageId;
    const page = this.object.pages.get(pageId);
    const tocNode = this.element[0].querySelector(`.directory-item[data-page-id="${pageId}"]`);
    if ( !tocNode || !toc ) return;
    const headings = Object.values(toc);
    headings.sort((a, b) => a.order - b.order);
    if ( page.title.show ) headings.shift();
    const minLevel = Math.min(...headings.map(node => node.level));
    tocNode.querySelector(":scope > ol")?.remove();
    const tocHTML = await renderTemplate("templates/journal/journal-page-toc.html", {
      headings: headings.reduce((arr, {text, level, slug, element}) => {
        if ( element ) element.dataset.anchor = slug;
        if ( level < minLevel + 2 ) arr.push({text, slug, level: level - minLevel + 2});
        return arr;
      }, [])
    });
    tocNode.innerHTML += tocHTML;
    tocNode.querySelectorAll(".heading-link").forEach(el =>
      el.addEventListener("click", this._onClickPageLink.bind(this)));
    this._dragDrop.forEach(d => d.bind(tocNode));
  }

  /* -------------------------------------------- */

  /**
   * Create an intersection observer to maintain a list of pages that are in view.
   * @protected
   */
  _observePages() {
    this.#pagesInView = [];
    this.#observer = new IntersectionObserver((entries, observer) => {
      this._onPageScroll(entries, observer);
      this._activatePagesInView();
      this._updateButtonState();
    }, {
      root: this.element.find(".journal-entry-pages .scrollable")[0],
      threshold: [0, .25, .5, .75, 1]
    });
    this.element.find(".journal-entry-page").each((i, el) => this.#observer.observe(el));
  }

  /* -------------------------------------------- */

  /**
   * Create an intersection observer to maintain a list of headings that are in view. This is much more performant than
   * calling getBoundingClientRect on all headings whenever we want to determine this list.
   * @protected
   */
  _observeHeadings() {
    const element = this.element[0];
    this.#headingIntersections = new Map();
    const headingObserver = new IntersectionObserver(entries => entries.forEach(entry => {
      if ( entry.isIntersecting ) this.#headingIntersections.set(entry.target, entry);
      else this.#headingIntersections.delete(entry.target);
    }), {
      root: element.querySelector(".journal-entry-pages .scrollable"),
      threshold: 1
    });
    const headings = Array.fromRange(6, 1).map(n => `h${n}`).join(",");
    element.querySelectorAll(`.journal-entry-page :is(${headings})`).forEach(el => headingObserver.observe(el));
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async close(options={}) {
    // Reset any temporarily-granted ownership.
    if ( this.#tempOwnership ) {
      this.object.ownership = foundry.utils.deepClone(this.object._source.ownership);
      this.object.pages.forEach(p => p.ownership = foundry.utils.deepClone(p._source.ownership));
      this.#tempOwnership = false;
    }
    return super.close(options);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking the previous and next page buttons.
   * @param {JQuery.TriggeredEvent} event  The button click event.
   * @protected
   */
  _onAction(event) {
    event.preventDefault();
    const button = event.currentTarget;
    const action = button.dataset.action;
    switch (action) {
      case "previous":
        return this.previousPage();
      case "next":
        return this.nextPage();
      case "createPage":
        return this.createPage();
      case "toggleView":
        const modes = this.constructor.VIEW_MODES;
        const mode = this.mode === modes.SINGLE ? modes.MULTIPLE : modes.SINGLE;
        this.#mode = mode;
        return this.render(true, {mode});
      case "toggleCollapse":
        return this.toggleSidebar(event);
      case "toggleSearch":
        this.toggleSearchMode();
        return this.render();
    }
  }

  /* -------------------------------------------- */

  /**
   * Prompt the user with a Dialog for creation of a new JournalEntryPage
   */
  createPage() {
    const bounds = this.element[0].getBoundingClientRect();
    const options = {parent: this.object, width: 320, top: bounds.bottom - 200, left: bounds.left + 10};
    const sort = (this._pages.at(-1)?.sort ?? 0) + CONST.SORT_INTEGER_DENSITY;
    return JournalEntryPage.implementation.createDialog({sort}, options);
  }

  /* -------------------------------------------- */

  /**
   * Turn to the previous page.
   */
  previousPage() {
    if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) return this.render(true, {pageIndex: this.pageIndex - 1});
    this.pagesInView[0]?.previousElementSibling?.scrollIntoView();
  }

  /* -------------------------------------------- */

  /**
   * Turn to the next page.
   */
  nextPage() {
    if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) return this.render(true, {pageIndex: this.pageIndex + 1});
    if ( this.pagesInView.length ) this.pagesInView.at(-1).nextElementSibling?.scrollIntoView();
    else this.element[0].querySelector(".journal-entry-page")?.scrollIntoView();
  }

  /* -------------------------------------------- */

  /**
   * Turn to a specific page.
   * @param {string} pageId    The ID of the page to turn to.
   * @param {string} [anchor]  Optionally an anchor slug to focus within that page.
   */
  goToPage(pageId, anchor) {
    if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) {
      const currentPageId = this._pages[this.pageIndex]?._id;
      if ( currentPageId !== pageId ) return this.render(true, {pageId, anchor});
    }
    const page = this.element[0].querySelector(`.journal-entry-page[data-page-id="${pageId}"]`);
    if ( anchor ) {
      const element = this.getPageSheet(pageId)?.toc[anchor]?.element;
      if ( element ) {
        element.scrollIntoView();
        return;
      }
    }
    page?.scrollIntoView();
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the sheet instance for rendering this page inline.
   * @param {string} pageId  The ID of the page.
   * @returns {JournalPageSheet}
   */
  getPageSheet(pageId) {
    const page = this.object.pages.get(pageId);
    const sheetClass = page._getSheetClass();
    let sheet = this.#sheets[pageId];
    if ( sheet?.constructor !== sheetClass ) {
      sheet = new sheetClass(page, {editable: false});
      this.#sheets[pageId] = sheet;
    }
    return sheet;
  }

  /* -------------------------------------------- */

  /**
   * Determine whether a page is visible to the current user.
   * @param {JournalEntryPage} page  The page.
   * @returns {boolean}
   */
  isPageVisible(page) {
    return this.getPageSheet(page.id)._canUserView(game.user);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the collapsed or expanded state of the Journal Entry table-of-contents sidebar.
   */
  toggleSidebar() {
    const app = this.element[0];
    const sidebar = app.querySelector(".sidebar");
    const button = sidebar.querySelector(".collapse-toggle");
    this.#sidebarState.collapsed = !this.sidebarCollapsed;

    // Disable application interaction temporarily
    app.style.pointerEvents = "none";

    // Configure CSS transitions for the application window
    app.classList.add("collapsing");
    app.addEventListener("transitionend", () => {
      app.style.pointerEvents = "";
      app.classList.remove("collapsing");
    }, {once: true});

    // Learn the configure sidebar widths
    const style = getComputedStyle(sidebar);
    const expandedWidth = Number(style.getPropertyValue("--sidebar-width-expanded").trim().replace("px", ""));
    const collapsedWidth = Number(style.getPropertyValue("--sidebar-width-collapsed").trim().replace("px", ""));

    // Change application position
    const delta = expandedWidth - collapsedWidth;
    this.setPosition({
      left: this.position.left + (this.sidebarCollapsed ? delta : -delta),
      width: this.position.width + (this.sidebarCollapsed ? -delta : delta)
    });

    // Toggle display of the sidebar
    sidebar.classList.toggle("collapsed", this.sidebarCollapsed);

    // Update icons and labels
    button.dataset.tooltip = this.sidebarCollapsed ? "JOURNAL.ViewExpand" : "JOURNAL.ViewCollapse";
    const i = button.children[0];
    i.setAttribute("class", `fa-solid ${this.sidebarCollapsed ? "fa-caret-left" : "fa-caret-right"}`);
    game.tooltip.deactivate();
  }

  /* -------------------------------------------- */

  /**
   * Update the disabled state of the previous and next page buttons.
   * @protected
   */
  _updateButtonState() {
    if ( !this.element?.length ) return;
    const previous = this.element[0].querySelector('[data-action="previous"]');
    const next = this.element[0].querySelector('[data-action="next"]');
    if ( !next || !previous ) return;
    if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) {
      previous.disabled = this.pageIndex < 1;
      next.disabled = this.pageIndex >= (this._pages.length - 1);
    } else {
      previous.disabled = !this.pagesInView[0]?.previousElementSibling;
      next.disabled = this.pagesInView.length && !this.pagesInView.at(-1).nextElementSibling;
    }
  }

  /* -------------------------------------------- */

  /**
   * Edit one of this JournalEntry's JournalEntryPages.
   * @param {JQuery.TriggeredEvent} event  The originating page edit event.
   * @protected
   */
  _onEditPage(event) {
    event.preventDefault();
    const button = event.currentTarget;
    const pageId = button.closest("[data-page-id]").dataset.pageId;
    const page = this.object.pages.get(pageId);
    return page?.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking an entry in the sidebar to scroll that heading into view.
   * @param {JQuery.TriggeredEvent} event  The originating click event.
   * @protected
   */
  _onClickPageLink(event) {
    const target = event.currentTarget;
    const pageId = target.closest("[data-page-id]").dataset.pageId;
    const anchor = target.closest("[data-anchor]")?.dataset.anchor;
    this.goToPage(pageId, anchor);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking an image to pop it out for fullscreen view.
   * @param {MouseEvent} event  The click event.
   * @protected
   */
  _onClickImage(event) {
    const target = event.currentTarget;
    const imagePage = target.closest(".journal-entry-page.image");
    const page = this.object.pages.get(imagePage?.dataset.pageId);
    const title = page?.name ?? target.title;
    const ip = new ImagePopout(target.getAttribute("src"), {title, caption: page?.image.caption});
    if ( page ) ip.shareImage = () => Journal.showDialog(page);
    ip.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle new pages scrolling into view.
   * @param {IntersectionObserverEntry[]} entries  An Array of elements that have scrolled into or out of view.
   * @param {IntersectionObserver} observer        The IntersectionObserver that invoked this callback.
   * @protected
   */
  _onPageScroll(entries, observer) {
    if ( !entries.length ) return;

    // This has been triggered by an old IntersectionObserver from the previous render and is no longer relevant.
    if ( observer !== this.observer ) return;

    // Case 1 - We are in single page mode.
    if ( this.mode === this.constructor.VIEW_MODES.SINGLE ) {
      const entry = entries[0]; // There can be only one entry in single page mode.
      if ( entry.isIntersecting ) this.#pagesInView = [entry.target];
      return;
    }

    const minRatio = this.constructor.INTERSECTION_RATIO;
    const intersecting = entries
      .filter(entry => entry.isIntersecting && (entry.intersectionRatio >= minRatio))
      .sort((a, b) => a.intersectionRect.y - b.intersectionRect.y);

    // Special case where the page is so large that any portion of visible content is less than 25% of the whole page.
    if ( !intersecting.length ) {
      const isIntersecting = entries.find(entry => entry.isIntersecting);
      if ( isIntersecting ) intersecting.push(isIntersecting);
    }

    // Case 2 - We are in multiple page mode and this is the first render.
    if ( !this.pagesInView.length ) {
      this.#pagesInView = intersecting.map(entry => entry.target);
      return;
    }

    // Case 3 - The user is scrolling normally through pages in multiple page mode.
    const byTarget = new Map(entries.map(entry => [entry.target, entry]));
    const inView = [...this.pagesInView];

    // Remove pages that have scrolled out of view.
    for ( const el of this.pagesInView ) {
      const entry = byTarget.get(el);
      if ( entry && (entry.intersectionRatio < minRatio) ) inView.findSplice(p => p === el);
    }

    // Add pages that have scrolled into view.
    for ( const entry of intersecting ) {
      if ( !inView.includes(entry.target) ) inView.push(entry.target);
    }

    this.#pagesInView = inView.sort((a, b) => {
      const pageA = this.object.pages.get(a.dataset.pageId);
      const pageB = this.object.pages.get(b.dataset.pageId);
      return pageA.sort - pageB.sort;
    });
  }

  /* -------------------------------------------- */

  /**
   * Highlights the currently viewed page in the sidebar.
   * @protected
   */
  _activatePagesInView() {
    // Update the pageIndex to the first page in view for when the mode is switched to single view.
    if ( this.pagesInView.length ) {
      const pageId = this.pagesInView[0].dataset.pageId;
      this.#pageIndex = this._pages.findIndex(p => p._id === pageId);
    }
    let activeChanged = false;
    const pageIds = new Set(this.pagesInView.map(p => p.dataset.pageId));
    this.element.find(".directory-item").each((i, el) => {
      activeChanged ||= (el.classList.contains("active") !== pageIds.has(el.dataset.pageId));
      el.classList.toggle("active", pageIds.has(el.dataset.pageId));
    });
    if ( activeChanged ) this._synchronizeSidebar();
  }

  /* -------------------------------------------- */

  /**
   * If the set of active pages has changed, various elements in the sidebar will expand and collapse. For particularly
   * long ToCs, this can leave the scroll position of the sidebar in a seemingly random state. We try to do our best to
   * sync the sidebar scroll position with the current journal viewport.
   * @protected
   */
  _synchronizeSidebar() {
    const entries = Array.from(this.#headingIntersections.values()).sort((a, b) => {
      return a.intersectionRect.y - b.intersectionRect.y;
    });
    for ( const entry of entries ) {
      const pageId = entry.target.closest("[data-page-id]")?.dataset.pageId;
      const anchor = entry.target.dataset.anchor;
      let toc = this.element[0].querySelector(`.directory-item[data-page-id="${pageId}"]`);
      if ( anchor ) toc = toc.querySelector(`li[data-anchor="${anchor}"]`);
      if ( toc ) {
        toc.scrollIntoView();
        break;
      }
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _contextMenu(html) {
    ContextMenu.create(this, html, ".directory-item", this._getEntryContextOptions(), {
      onOpen: this._onContextMenuOpen.bind(this),
      onClose: this._onContextMenuClose.bind(this)
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle opening the context menu.
   * @param {HTMLElement} target  The element the context menu has been triggered for.
   * @protected
   */
  _onContextMenuOpen(target) {
    this.#sidebarState = {
      position: this.element.find(".directory-list.scrollable").scrollTop(),
      active: target.classList.contains("active")
    };
    target.classList.remove("active");
  }

  /* -------------------------------------------- */

  /**
   * Handle closing the context menu.
   * @param {HTMLElement} target  The element the context menu has been triggered for.
   * @protected
   */
  _onContextMenuClose(target) {
    if ( this.#sidebarState.active ) target.classList.add("active");
    this.element.find(".directory-list.scrollable").scrollTop(this.#sidebarState.position);
  }

  /* -------------------------------------------- */

  /**
   * Get the set of ContextMenu options which should be used for JournalEntryPages in the sidebar.
   * @returns {ContextMenuEntry[]}  The Array of context options passed to the ContextMenu instance.
   * @protected
   */
  _getEntryContextOptions() {
    const getPage = li => this.object.pages.get(li.data("page-id"));
    return [{
      name: "SIDEBAR.Edit",
      icon: '<i class="fas fa-edit"></i>',
      condition: li => this.isEditable && getPage(li)?.canUserModify(game.user, "update"),
      callback: li => getPage(li)?.sheet.render(true)
    }, {
      name: "SIDEBAR.Delete",
      icon: '<i class="fas fa-trash"></i>',
      condition: li => this.isEditable && getPage(li)?.canUserModify(game.user, "delete"),
      callback: li => {
        const bounds = li[0].getBoundingClientRect();
        return getPage(li)?.deleteDialog({top: bounds.top, left: bounds.right});
      }
    }, {
      name: "SIDEBAR.Duplicate",
      icon: '<i class="far fa-copy"></i>',
      condition: this.isEditable,
      callback: li => {
        const page = getPage(li);
        return page.clone({name: game.i18n.format("DOCUMENT.CopyOf", {name: page.name})}, {
          save: true, addSource: true
        });
      }
    }, {
      name: "OWNERSHIP.Configure",
      icon: '<i class="fas fa-lock"></i>',
      condition: () => game.user.isGM,
      callback: li => {
        const page = getPage(li);
        const bounds = li[0].getBoundingClientRect();
        new DocumentOwnershipConfig(page, {top: bounds.top, left: bounds.right}).render(true);
      }
    }, {
      name: "JOURNAL.ActionShow",
      icon: '<i class="fas fa-eye"></i>',
      condition: li => getPage(li)?.isOwner,
      callback: li => {
        const page = getPage(li);
        if ( page ) return Journal.showDialog(page);
      }
    }, {
      name: "SIDEBAR.JumpPin",
      icon: '<i class="fa-solid fa-crosshairs"></i>',
      condition: li => {
        const page = getPage(li);
        return !!page?.sceneNote;
      },
      callback: li => {
        const page = getPage(li);
        if ( page?.sceneNote ) return canvas.notes.panToNote(page.sceneNote);
      }
    }];
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {
    // Remove <form> tags which will break the display of the sheet.
    if ( formData.content ) formData.content = formData.content.replace(/<\s*\/?\s*form(\s+[^>]*)?>/g, "");
    return super._updateObject(event, formData);
  }

  /* -------------------------------------------- */

  /**
   * Handle requests to show the referenced Journal Entry to other Users
   * Save the form before triggering the show request, in case content has changed
   * @param {Event} event   The triggering click event
   */
  async _onShowPlayers(event) {
    event.preventDefault();
    await this.submit();
    return Journal.showDialog(this.object);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _canDragStart(selector) {
    return this.object.testUserPermission(game.user, "OBSERVER");
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _canDragDrop(selector) {
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragStart(event) {
    if ( ui.context ) ui.context.close({animate: false});
    const target = event.currentTarget;
    const pageId = target.closest("[data-page-id]").dataset.pageId;
    const anchor = target.closest("[data-anchor]")?.dataset.anchor;
    const page = this.object.pages.get(pageId);
    const dragData = {
      ...page.toDragData(),
      anchor: { slug: anchor, name: target.innerText }
    };
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onDrop(event) {
    // Retrieve the dropped Journal Entry Page
    const data = TextEditor.getDragEventData(event);
    if ( data.type !== "JournalEntryPage" ) return;
    const page = await JournalEntryPage.implementation.fromDropData(data);
    if ( !page ) return;

    // Determine the target that was dropped
    const target = event.target.closest("[data-page-id]");
    const sortTarget = target ? this.object.pages.get(target?.dataset.pageId) : null;

    // Prevent dropping a page on itself.
    if ( page === sortTarget ) return;

    // Case 1 - Sort Pages
    if ( page.parent === this.document ) return page.sortRelative({
      sortKey: "sort",
      target: sortTarget,
      siblings: this.object.pages.filter(p => p.id !== page.id)
    });

    // Case 2 - Create Pages
    const pageData = page.toObject();
    if ( this.object.pages.has(page.id) ) delete pageData._id;
    pageData.sort = sortTarget ? sortTarget.sort : this.object.pages.reduce((max, p) => p.sort > max ? p.sort : max, 0);
    return this.document.createEmbeddedDocuments("JournalEntryPage", [pageData], {keepId: true});
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onSearchFilter(event, query, rgx, html) {
    this.#filteredPages.clear();
    const nameOnlySearch = (this.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME);

    // Match Pages
    let results = [];
    if ( !nameOnlySearch ) results = this.object.pages.search({query: query});
    for ( const el of html.querySelectorAll(".directory-item") ) {
      const page = this.object.pages.get(el.dataset.pageId);
      let match = !query;
      if ( !match && nameOnlySearch ) match = rgx.test(SearchFilter.cleanQuery(page.name));
      else if ( !match ) match = !!results.find(r => r._id === page._id);
      if ( match ) this.#filteredPages.add(page._id);
      el.classList.toggle("hidden", !match);
    }

    // Restore TOC Positions
    if ( this.#restoreTOCPositions && this._scrollPositions ) {
      this.#restoreTOCPositions = false;
      const position = this._scrollPositions[this.options.scrollY[0]]?.[0];
      const toc = this.element[0].querySelector(".pages-list .scrollable");
      if ( position && toc ) toc.scrollTop = position;
    }
  }
}

/**
 * A Macro configuration sheet
 * @extends {DocumentSheet}
 *
 * @param {Macro} object                    The Macro Document which is being configured
 * @param {DocumentSheetOptions} [options]  Application configuration options.
 */
class MacroConfig extends DocumentSheet {

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["sheet", "macro-sheet"],
      template: "templates/sheets/macro-config.html",
      width: 560,
      height: 480,
      resizable: true
    });
  }

  /**
   * Should this Macro be created in a specific hotbar slot?
   * @internal
   */
  _hotbarSlot;

  /* -------------------------------------------- */

  /** @override */
  getData(options={}) {
    const data = super.getData();
    data.macroTypes = game.documentTypes.Macro.map(t => ({
      value: t,
      label: game.i18n.localize(CONFIG.Macro.typeLabels[t]),
      disabled: (t === "script") && !game.user.can("MACRO_SCRIPT")
    }));
    data.macroScopes = CONST.MACRO_SCOPES.map(s => ({value: s, label: s}));
    return data;
  }

  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("button.execute").click(this.#onExecute.bind(this));
    html.find('select[name="type"]').change(this.#updateCommandDisabled.bind(this));
    this.#updateCommandDisabled();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _disableFields(form) {
    super._disableFields(form);
    if ( this.object.canExecute ) form.querySelector("button.execute").disabled = false;
  }

  /* -------------------------------------------- */

  /**
   * Update the disabled state of the command textarea.
   */
  #updateCommandDisabled() {
    const type = this.element[0].querySelector('select[name="type"]').value;
    this.element[0].querySelector('textarea[name="command"]').disabled = (type === "script") && !game.user.can("MACRO_SCRIPT");
  }

  /* -------------------------------------------- */

  /**
   * Save and execute the macro using the button on the configuration sheet
   * @param {MouseEvent} event      The originating click event
   * @returns {Promise<void>}
   */
  async #onExecute(event) {
    event.preventDefault();
    await this._updateObject(event, this._getSubmitData()); // Submit pending changes
    this.object.execute(); // Execute the macro
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    const updateData = foundry.utils.expandObject(formData);
    try {
      if ( this.object.id ) {
        this.object.updateSource(updateData, { dryRun: true, fallback: false });
        return await super._updateObject(event, formData);
      } else {
        const macro = await Macro.implementation.create(new Macro.implementation(updateData));
        if ( !macro ) throw new Error("Failed to create Macro");
        this.object = macro;
        await game.user.assignHotbarMacro(macro, this._hotbarSlot);
      }
    } catch(err) {
      Hooks.onError("MacroConfig#_updateObject", err, { notify: "error" });
      throw err;
    }
  }
}

/**
 * The Application responsible for configuring a single MeasuredTemplate document within a parent Scene.
 * @param {MeasuredTemplate} object         The {@link MeasuredTemplate} being configured.
 * @param {DocumentSheetOptions} [options]  Application configuration options.
 */
class MeasuredTemplateConfig extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "template-config",
      classes: ["sheet", "template-sheet"],
      title: "TEMPLATE.MeasuredConfig",
      template: "templates/scene/template-config.html",
      width: 400
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData() {
    return foundry.utils.mergeObject(super.getData(), {
      templateTypes: CONFIG.MeasuredTemplate.types,
      gridUnits: this.document.parent.grid.units || game.i18n.localize("GridUnits"),
      userColor: game.user.color,
      submitText: `TEMPLATE.Submit${this.options.preview ? "Create" : "Update"}`
    });
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    if ( this.object.id ) {
      formData.id = this.object.id;
      return this.object.update(formData);
    }
    return this.object.constructor.create(formData);
  }
}

/**
 * A generic application for configuring permissions for various Document types.
 */
class DocumentOwnershipConfig extends DocumentSheet {

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "permission",
      template: "templates/apps/ownership.html",
      width: 400
    });
  }

  /**
   * Are Gamemaster users currently hidden?
   * @type {boolean}
   */
  static #gmHidden = true;

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return `${game.i18n.localize("OWNERSHIP.Title")}: ${this.document.name}`;
  }

  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    const isFolder = this.document instanceof Folder;
    const isEmbedded = this.document.isEmbedded;
    const ownership = this.document.ownership;
    if ( !ownership && !isFolder ) {
      throw new Error(`The ${this.document.documentName} document does not contain ownership data`);
    }

    // User permission levels
    const playerLevels = Object.entries(CONST.DOCUMENT_META_OWNERSHIP_LEVELS).map(([name, level]) => {
      return {level, label: game.i18n.localize(`OWNERSHIP.${name}`)};
    });

    if ( !isFolder ) playerLevels.pop();
    for ( let [name, level] of Object.entries(CONST.DOCUMENT_OWNERSHIP_LEVELS) ) {
      if ( (level < 0) && !isEmbedded ) continue;
      playerLevels.push({level, label: game.i18n.localize(`OWNERSHIP.${name}`)});
    }

    // Default permission levels
    const defaultLevels = foundry.utils.deepClone(playerLevels);
    defaultLevels.shift();

    // Player users
    const users = game.users.map(user => {
      return {
        user,
        level: isFolder ? CONST.DOCUMENT_META_OWNERSHIP_LEVELS.NOCHANGE : ownership[user.id],
        isAuthor: this.document.author === user,
        cssClass: user.isGM ? "gm" : "",
        icon: user.isGM ? "fa-solid fa-crown": "",
        tooltip: user.isGM ? game.i18n.localize("USER.RoleGamemaster") : ""
      };
    }).sort((a, b) => a.user.name.localeCompare(b.user.name, game.i18n.lang));

    // Construct and return the data object
    return {
      currentDefault: ownership?.default ?? CONST.DOCUMENT_META_OWNERSHIP_LEVELS.DEFAULT,
      instructions: game.i18n.localize(isFolder ? "OWNERSHIP.HintFolder" : "OWNERSHIP.HintDocument"),
      defaultLevels,
      playerLevels,
      isFolder,
      showGM: !DocumentOwnershipConfig.#gmHidden,
      users
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);

    // Toggle GM user visibility
    const toggle = html[0].querySelector("input#show-gm-toggle");
    toggle.addEventListener("change", () => this.#toggleGamemasters());
    this.#toggleGamemasters(DocumentOwnershipConfig.#gmHidden);
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    event.preventDefault();
    if ( !game.user.isGM ) throw new Error("You do not have the ability to configure permissions.");
    // Collect new ownership levels from the form data
    const metaLevels = CONST.DOCUMENT_META_OWNERSHIP_LEVELS;
    const isFolder = this.document instanceof Folder;
    const omit = isFolder ? metaLevels.NOCHANGE : metaLevels.DEFAULT;
    const ownershipLevels = {};
    for ( let [user, level] of Object.entries(formData) ) {
      if ( level === omit ) {
        delete ownershipLevels[user];
        continue;
      }
      ownershipLevels[user] = level;
    }

    // Update all documents in a Folder
    if ( this.document instanceof Folder ) {
      const cls = getDocumentClass(this.document.type);
      const updates = this.document.contents.map(d => {
        const ownership = foundry.utils.deepClone(d.ownership);
        for ( let [k, v] of Object.entries(ownershipLevels) ) {
          if ( v === metaLevels.DEFAULT ) delete ownership[k];
          else ownership[k] = v;
        }
        return {_id: d.id, ownership};
      });
      return cls.updateDocuments(updates, {diff: false, recursive: false, noHook: true});
    }

    // Update a single Document
    return this.document.update({ownership: ownershipLevels}, {diff: false, recursive: false, noHook: true});
  }

  /* -------------------------------------------- */

  /**
   * Toggle CSS classes which display or hide gamemaster users
   * @param {boolean} hidden      Should gamemaster users be hidden?
   */
  #toggleGamemasters(hidden) {
    hidden ??= !DocumentOwnershipConfig.#gmHidden;
    this.form.classList.toggle("no-gm", hidden);
    DocumentOwnershipConfig.#gmHidden = hidden;
    this.setPosition({height: "auto", width: this.options.width});
  }
}

/**
 * The Application responsible for configuring a single Playlist document.
 * @extends {DocumentSheet}
 * @param {Playlist} object                 The {@link Playlist} to configure.
 * @param {DocumentSheetOptions} [options]  Application configuration options.
 */
class PlaylistConfig extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.id = "playlist-config";
    options.template = "templates/playlist/playlist-config.html";
    options.width = 360;
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get title() {
    return `${game.i18n.localize("PLAYLIST.Edit")}: ${this.object.name}`;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    const data = super.getData(options);
    data.modes = Object.entries(CONST.PLAYLIST_MODES).reduce((obj, e) => {
      const [name, value] = e;
      obj[value] = game.i18n.localize(`PLAYLIST.Mode${name.titleCase()}`);
      return obj;
    }, {});
    data.sorting = Object.entries(CONST.PLAYLIST_SORT_MODES).reduce((obj, [name, value]) => {
      obj[value] = game.i18n.localize(`PLAYLIST.Sort${name.titleCase()}`);
      return obj;
    }, {});
    data.channels = CONST.AUDIO_CHANNELS;
    return data;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("file-picker").on("change", this._onBulkImport.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Special actions to take when a bulk-import path is selected in the FilePicker.
   * @param {Event} event     The <file-picker> change event
   */
  async _onBulkImport(event) {

    // Get audio files
    const fp = event.target;
    fp.picker.type = "audio";
    const contents = await fp.picker.browse(fp.value);
    fp.picker.type = "folder";
    if ( !contents?.files?.length ) return;

    // Prepare PlaylistSound data
    const playlist = this.object;
    const currentSources = new Set(playlist.sounds.map(s => s.path));
    const toCreate = contents.files.reduce((arr, src) => {
      if ( !AudioHelper.hasAudioExtension(src) || currentSources.has(src) ) return arr;
      const soundData = { name: foundry.audio.AudioHelper.getDefaultSoundName(src), path: src };
      arr.push(soundData);
      return arr;
    }, []);

    // Create all PlaylistSound documents
    if ( toCreate.length ) {
      ui.playlists._expanded.add(playlist.id);
      return playlist.createEmbeddedDocuments("PlaylistSound", toCreate);
    } else {
      const warning = game.i18n.format("PLAYLIST.BulkImportWarning", {path: filePicker.target});
      return ui.notifications.warn(warning);
    }
  }
}

/**
 * The Application responsible for configuring a single PlaylistSound document within a parent Playlist.
 * @extends {DocumentSheet}
 *
 * @param {PlaylistSound} sound             The PlaylistSound document being configured
 * @param {DocumentSheetOptions} [options]  Additional application rendering options
 */
class PlaylistSoundConfig extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "track-config",
      template: "templates/playlist/sound-config.html",
      width: 360
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get title() {
    if ( !this.object.id ) return `${game.i18n.localize("PLAYLIST.SoundCreate")}: ${this.object.parent.name}`;
    return `${game.i18n.localize("PLAYLIST.SoundEdit")}: ${this.object.name}`;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    const context = super.getData(options);
    if ( !this.document.id ) context.data.name = "";
    context.lvolume = foundry.audio.AudioHelper.volumeToInput(this.document.volume);
    context.channels = CONST.AUDIO_CHANNELS;
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers
  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('input[name="path"]').change(this._onSourceChange.bind(this));
    return html;
  }

  /* -------------------------------------------- */

  /**
   * Auto-populate the track name using the provided filename, if a name is not already set
   * @param {Event} event
   * @private
   */
  _onSourceChange(event) {
    event.preventDefault();
    const field = event.target;
    const form = field.form;
    if ( !form.name.value ) {
      form.name.value = foundry.audio.AudioHelper.getDefaultSoundName(field.value);
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {
    formData["volume"] = foundry.audio.AudioHelper.inputToVolume(formData["lvolume"]);
    if (this.object.id)  return this.object.update(formData);
    return this.object.constructor.create(formData, {parent: this.object.parent});
  }
}

/**
 * The Application responsible for displaying and editing a single RollTable document.
 * @param {RollTable} table                 The RollTable document being configured
 * @param {DocumentSheetOptions} [options]  Additional application configuration options
 */
class RollTableConfig extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["sheet", "roll-table-config"],
      template: "templates/sheets/roll-table-config.html",
      width: 720,
      height: "auto",
      closeOnSubmit: false,
      viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
      scrollY: ["table.table-results tbody"],
      dragDrop: [{dragSelector: null, dropSelector: null}]
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get title() {
    return `${game.i18n.localize("TABLE.SheetTitle")}: ${this.document.name}`;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async getData(options={}) {
    const context = super.getData(options);
    context.descriptionHTML = await TextEditor.enrichHTML(this.object.description, {secrets: this.object.isOwner});
    const results = this.document.results.map(result => {
      result = result.toObject(false);
      result.isText = result.type === CONST.TABLE_RESULT_TYPES.TEXT;
      result.isDocument = result.type === CONST.TABLE_RESULT_TYPES.DOCUMENT;
      result.isCompendium = result.type === CONST.TABLE_RESULT_TYPES.COMPENDIUM;
      result.img = result.img || CONFIG.RollTable.resultIcon;
      result.text = TextEditor.decodeHTML(result.text);
      return result;
    });
    results.sort((a, b) => a.range[0] - b.range[0]);

    // Merge data and return;
    return foundry.utils.mergeObject(context, {
      results,
      resultTypes: Object.entries(CONST.TABLE_RESULT_TYPES).reduce((obj, v) => {
        obj[v[1]] = game.i18n.localize(`TABLE.RESULT_TYPES.${v[0]}.label`);
        return obj;
      }, {}),
      documentTypes: CONST.COMPENDIUM_DOCUMENT_TYPES.map(d =>
        ({value: d, label: game.i18n.localize(getDocumentClass(d).metadata.label)})),
      compendiumPacks: Array.from(game.packs.keys()).map(k => ({value: k, label: k}))
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);

    // We need to disable roll button if the document is not editable AND has no formula
    if ( !this.isEditable && !this.document.formula ) return;

    // Roll the Table
    const button = html.find("button.roll");
    button.click(this._onRollTable.bind(this));
    button[0].disabled = false;

    // The below options require an editable sheet
    if ( !this.isEditable ) return;

    // Reset the Table
    html.find("button.reset").click(this._onResetTable.bind(this));

    // Save the sheet on checkbox change
    html.find('input[type="checkbox"]').change(this._onSubmit.bind(this));

    // Create a new Result
    html.find("a.create-result").click(this._onCreateResult.bind(this));

    // Delete a Result
    html.find("a.delete-result").click(this._onDeleteResult.bind(this));

    // Lock or Unlock a Result
    html.find("a.lock-result").click(this._onLockResult.bind(this));

    // Modify Result Type
    html.find(".result-type select").change(this._onChangeResultType.bind(this));

    // Re-normalize Table Entries
    html.find(".normalize-results").click(this._onNormalizeResults.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a TableResult in the RollTable document
   * @param {MouseEvent} event        The originating mouse event
   * @param {object} [resultData]     An optional object of result data to use
   * @returns {Promise}
   * @private
   */
  async _onCreateResult(event, resultData={}) {
    event.preventDefault();

    // Save any pending changes
    await this._onSubmit(event);

    // Get existing results
    const results = Array.from(this.document.results.values());
    let last = results[results.length - 1];

    // Get weight and range data
    let weight = last ? (last.weight || 1) : 1;
    let totalWeight = results.reduce((t, r) => t + r.weight, 0) || 1;
    let minRoll = results.length ? Math.min(...results.map(r => r.range[0])) : 0;
    let maxRoll = results.length ? Math.max(...results.map(r => r.range[1])) : 0;

    // Determine new starting range
    const spread = maxRoll - minRoll + 1;
    const perW = Math.round(spread / totalWeight);
    const range = [maxRoll + 1, maxRoll + Math.max(1, weight * perW)];

    // Create the new Result
    resultData = foundry.utils.mergeObject({
      type: last ? last.type : CONST.TABLE_RESULT_TYPES.TEXT,
      documentCollection: last ? last.documentCollection : null,
      weight: weight,
      range: range,
      drawn: false
    }, resultData);
    return this.document.createEmbeddedDocuments("TableResult", [resultData]);
  }

  /* -------------------------------------------- */

  /**
   * Submit the entire form when a table result type is changed, in case there are other active changes
   * @param {Event} event
   * @private
   */
  _onChangeResultType(event) {
    event.preventDefault();
    const rt = CONST.TABLE_RESULT_TYPES;
    const select = event.target;
    const value = parseInt(select.value);
    const resultKey = select.name.replace(".type", "");
    let documentCollection = "";
    if ( value === rt.DOCUMENT ) documentCollection = "Actor";
    else if ( value === rt.COMPENDIUM ) documentCollection = game.packs.keys().next().value;
    const updateData = {[resultKey]: {documentCollection, documentId: null}};
    return this._onSubmit(event, {updateData});
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting a TableResult from the RollTable document
   * @param {MouseEvent} event        The originating click event
   * @returns {Promise<TableResult>}   The deleted TableResult document
   * @private
   */
  async _onDeleteResult(event) {
    event.preventDefault();
    await this._onSubmit(event);
    const li = event.currentTarget.closest(".table-result");
    const result = this.object.results.get(li.dataset.resultId);
    return result.delete();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onDrop(event) {
    const data = TextEditor.getDragEventData(event);
    const allowed = Hooks.call("dropRollTableSheetData", this.document, this, data);
    if ( allowed === false ) return;

    // Get the dropped document
    if ( !CONST.COMPENDIUM_DOCUMENT_TYPES.includes(data.type) ) return;
    const cls = getDocumentClass(data.type);
    const document = await cls.fromDropData(data);
    if ( !document || document.isEmbedded ) return;

    // Delegate to the onCreate handler
    const isCompendium = !!document.compendium;
    return this._onCreateResult(event, {
      type: isCompendium ? CONST.TABLE_RESULT_TYPES.COMPENDIUM : CONST.TABLE_RESULT_TYPES.DOCUMENT,
      documentCollection: isCompendium ? document.pack : document.documentName,
      text: document.name,
      documentId: document.id,
      img: document.img || null
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the actor profile image by opening a FilePicker
   * @param {Event} event
   * @private
   */
  _onEditImage(event) {
    const img = event.currentTarget;
    const isHeader = img.dataset.edit === "img";
    let current = this.document.img;
    if ( !isHeader ) {
      const li = img.closest(".table-result");
      const result = this.document.results.get(li.dataset.resultId);
      current = result.img;
    }
    const fp = new FilePicker({
      type: "image",
      current: current,
      callback: path => {
        img.src = path;
        return this._onSubmit(event);
      },
      top: this.position.top + 40,
      left: this.position.left + 10
    });
    return fp.browse();
  }

  /* -------------------------------------------- */

  /**
   * Handle a button click to re-normalize dice result ranges across all RollTable results
   * @param {Event} event
   * @private
   */
  async _onNormalizeResults(event) {
    event.preventDefault();
    if ( !this.rendered || this._submitting) return false;

    // Save any pending changes
    await this._onSubmit(event);

    // Normalize the RollTable
    return this.document.normalize();
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the drawn status of the result in the table
   * @param {Event} event
   * @private
   */
  _onLockResult(event) {
    event.preventDefault();
    const tableResult = event.currentTarget.closest(".table-result");
    const result = this.document.results.get(tableResult.dataset.resultId);
    return result.update({drawn: !result.drawn});
  }

  /* -------------------------------------------- */

  /**
   * Reset the Table to it's original composition with all options unlocked
   * @param {Event} event
   * @private
   */
  _onResetTable(event) {
    event.preventDefault();
    return this.document.resetResults();
  }

  /* -------------------------------------------- */

  /**
   * Handle drawing a result from the RollTable
   * @param {Event} event
   * @private
   */
  async _onRollTable(event) {
    event.preventDefault();
    await this.submit({preventClose: true, preventRender: true});
    event.currentTarget.disabled = true;
    let tableRoll = await this.document.roll();
    const draws = this.document.getResultsForRoll(tableRoll.roll.total);
    if ( draws.length ) {
      if (game.settings.get("core", "animateRollTable")) await this._animateRoll(draws);
      await this.document.draw(tableRoll);
    }
    event.currentTarget.disabled = false;
  }

  /* -------------------------------------------- */

  /**
   * Configure the update object workflow for the Roll Table configuration sheet
   * Additional logic is needed here to reconstruct the results array from the editable fields on the sheet
   * @param {Event} event            The form submission event
   * @param {Object} formData        The validated FormData translated into an Object for submission
   * @returns {Promise}
   * @private
   */
  async _updateObject(event, formData) {
    // Expand the data to update the results array
    const expanded = foundry.utils.expandObject(formData);
    expanded.results = expanded.hasOwnProperty("results") ? Object.values(expanded.results) : [];
    for (let r of expanded.results) {
      r.range = [r.rangeL, r.rangeH];
      switch (r.type) {

        // Document results
        case CONST.TABLE_RESULT_TYPES.DOCUMENT:
          const collection = game.collections.get(r.documentCollection);
          if (!collection) continue;

          // Get the original document, if the name still matches - take no action
          const original = r.documentId ? collection.get(r.documentId) : null;
          if (original && (original.name === r.text)) continue;

          // Otherwise, find the document by ID or name (ID preferred)
          const doc = collection.find(e => (e.id === r.text) || (e.name === r.text)) || null;
          r.documentId = doc?.id ?? null;
          r.text = doc?.name ?? null;
          r.img = doc?.img ?? null;
          r.img = doc?.thumb || doc?.img || null;
          break;

        // Compendium results
        case CONST.TABLE_RESULT_TYPES.COMPENDIUM:
          const pack = game.packs.get(r.documentCollection);
          if (pack) {

            // Get the original entry, if the name still matches - take no action
            const original = pack.index.get(r.documentId) || null;
            if (original && (original.name === r.text)) continue;

            // Otherwise, find the document by ID or name (ID preferred)
            const doc = pack.index.find(i => (i._id === r.text) || (i.name === r.text)) || null;
            r.documentId = doc?._id || null;
            r.text = doc?.name || null;
            r.img = doc?.thumb || doc?.img || null;
          }
          break;

        // Plain text results
        default:
          r.type = CONST.TABLE_RESULT_TYPES.TEXT;
          r.documentCollection = null;
          r.documentId = null;
      }
    }

    // Update the object
    return this.document.update(expanded, {diff: false, recursive: false});
  }

  /* -------------------------------------------- */

  /**
   * Display a roulette style animation when a Roll Table result is drawn from the sheet
   * @param {TableResult[]} results     An Array of drawn table results to highlight
   * @returns {Promise}                  A Promise which resolves once the animation is complete
   * @protected
   */
  async _animateRoll(results) {

    // Get the list of results and their indices
    const tableResults = this.element[0].querySelector(".table-results > tbody");
    const drawnIds = new Set(results.map(r => r.id));
    const drawnItems = Array.from(tableResults.children).filter(item => drawnIds.has(item.dataset.resultId));

    // Set the animation timing
    const nResults = this.object.results.size;
    const maxTime = 2000;
    let animTime = 50;
    let animOffset = Math.round(tableResults.offsetHeight / (tableResults.children[0].offsetHeight * 2));
    const nLoops = Math.min(Math.ceil(maxTime/(animTime * nResults)), 4);
    if ( nLoops === 1 ) animTime = maxTime / nResults;

    // Animate the roulette
    await this._animateRoulette(tableResults, drawnIds, nLoops, animTime, animOffset);

    // Flash the results
    const flashes = drawnItems.map(li => this._flashResult(li));
    return Promise.all(flashes);
  }

  /* -------------------------------------------- */

  /**
   * Animate a "roulette" through the table until arriving at the final loop and a drawn result
   * @param {HTMLOListElement} ol     The list element being iterated
   * @param {Set<string>} drawnIds    The result IDs which have already been drawn
   * @param {number} nLoops           The number of times to loop through the animation
   * @param {number} animTime         The desired animation time in milliseconds
   * @param {number} animOffset       The desired pixel offset of the result within the list
   * @returns {Promise}               A Promise that resolves once the animation is complete
   * @protected
   */
  async _animateRoulette(ol, drawnIds, nLoops, animTime, animOffset) {
    let loop = 0;
    let idx = 0;
    let item = null;
    return new Promise(resolve => {
      let animId = setInterval(() => {
        if (idx === 0) loop++;
        if (item) item.classList.remove("roulette");

        // Scroll to the next item
        item = ol.children[idx];
        ol.scrollTop = (idx - animOffset) * item.offsetHeight;

        // If we are on the final loop
        if ( (loop === nLoops) && drawnIds.has(item.dataset.resultId) ) {
          clearInterval(animId);
          return resolve();
        }

        // Continue the roulette and cycle the index
        item.classList.add("roulette");
        idx = idx < ol.children.length - 1 ? idx + 1 : 0;
      }, animTime);
    });
  }

  /* -------------------------------------------- */

  /**
   * Display a flashing animation on the selected result to emphasize the draw
   * @param {HTMLElement} item      The HTML &lt;li> item of the winning result
   * @returns {Promise}              A Promise that resolves once the animation is complete
   * @protected
   */
  async _flashResult(item) {
    return new Promise(resolve => {
      let count = 0;
      let animId = setInterval(() => {
        if (count % 2) item.classList.remove("roulette");
        else item.classList.add("roulette");
        if (count === 7) {
          clearInterval(animId);
          resolve();
        }
        count++;
      }, 50);
    });
  }
}

/**
 * The Application responsible for configuring a single Scene document.
 * @extends {DocumentSheet}
 * @param {Scene} object                    The Scene Document which is being configured
 * @param {DocumentSheetOptions} [options]  Application configuration options.
 */
class SceneConfig extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "scene-config",
      classes: ["sheet", "scene-sheet"],
      template: "templates/scene/config.html",
      width: 560,
      height: "auto",
      tabs: [
        {navSelector: '.tabs[data-group="main"]', contentSelector: "form", initial: "basic"},
        {navSelector: '.tabs[data-group="ambience"]', contentSelector: '.tab[data-tab="ambience"]', initial: "basic"}
      ]
    });
  }

  /* -------------------------------------------- */

  /**
   * Indicates if width / height should change together to maintain aspect ratio
   * @type {boolean}
   */
  linkedDimensions = true;

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return `${game.i18n.localize("SCENES.ConfigTitle")}: ${this.object.name}`;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async close(options={}) {
    this._resetScenePreview();
    return super.close(options);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  render(force, options={}) {
    if ( options.renderContext && !["createScene", "updateScene"].includes(options.renderContext) ) return this;
    return super.render(force, options);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    const context = super.getData(options);
    context.data = this.document.toObject();  // Source data, not derived
    context.playlistSound = this.document.playlistSound?.id || "";
    context.foregroundElevation = this.document.foregroundElevation;

    // Selectable types
    context.minGrid = CONST.GRID_MIN_SIZE;
    context.gridTypes = this.constructor._getGridTypes();
    context.gridStyles = CONFIG.Canvas.gridStyles;
    context.weatherTypes = this._getWeatherTypes();
    context.ownerships = [
      {value: 0, label: "SCENES.AccessibilityGM"},
      {value: 2, label: "SCENES.AccessibilityAll"}
    ];

    // Referenced documents
    context.playlists = this._getDocuments(game.playlists);
    context.sounds = this._getDocuments(this.object.playlist?.sounds ?? []);
    context.journals = this._getDocuments(game.journal);
    context.pages = this.object.journal?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
    context.isEnvironment = (this._tabs[0].active === "ambience") && (this._tabs[1].active === "environment");
    context.baseHueSliderDisabled = (this.document.environment.base.intensity === 0);
    context.darknessHueSliderDisabled = (this.document.environment.dark.intensity === 0);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Get an enumeration of the available grid types which can be applied to this Scene
   * @returns {object}
   * @internal
   */
  static _getGridTypes() {
    const labels = {
      GRIDLESS: "SCENES.GridGridless",
      SQUARE: "SCENES.GridSquare",
      HEXODDR: "SCENES.GridHexOddR",
      HEXEVENR: "SCENES.GridHexEvenR",
      HEXODDQ: "SCENES.GridHexOddQ",
      HEXEVENQ: "SCENES.GridHexEvenQ"
    };
    return Object.keys(CONST.GRID_TYPES).reduce((obj, t) => {
      obj[CONST.GRID_TYPES[t]] = labels[t];
      return obj;
    }, {});
  }

  /* --------------------------------------------- */

  /** @inheritdoc */
  async _renderInner(...args) {
    await loadTemplates([
      "templates/scene/parts/scene-ambience.html"
    ]);
    return super._renderInner(...args);
  }

  /* -------------------------------------------- */

  /**
   * Get the available weather effect types which can be applied to this Scene
   * @returns {object}
   * @private
   */
  _getWeatherTypes() {
    const types = {};
    for ( let [k, v] of Object.entries(CONFIG.weatherEffects) ) {
      types[k] = game.i18n.localize(v.label);
    }
    return types;
  }

  /* -------------------------------------------- */

  /**
   * Get the alphabetized Documents which can be chosen as a configuration for the Scene
   * @param {WorldCollection} collection
   * @returns {object[]}
   * @private
   */
  _getDocuments(collection) {
    const documents = collection.map(doc => {
      return {id: doc.id, name: doc.name};
    });
    documents.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
    return documents;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("button.capture-position").click(this._onCapturePosition.bind(this));
    html.find("button.grid-config").click(this._onGridConfig.bind(this));
    html.find("button.dimension-link").click(this._onLinkDimensions.bind(this));
    html.find("select[name='playlist']").change(this._onChangePlaylist.bind(this));
    html.find('select[name="journal"]').change(this._onChangeJournal.bind(this));
    html.find('button[type="reset"]').click(this._onResetForm.bind(this));
    html.find("hue-slider").change(this._onChangeRange.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Capture the current Scene position and zoom level as the initial view in the Scene config
   * @param {Event} event   The originating click event
   * @private
   */
  _onCapturePosition(event) {
    event.preventDefault();
    if ( !canvas.ready ) return;
    const btn = event.currentTarget;
    const form = btn.form;
    form["initial.x"].value = parseInt(canvas.stage.pivot.x);
    form["initial.y"].value = parseInt(canvas.stage.pivot.y);
    form["initial.scale"].value = canvas.stage.scale.x;
    ui.notifications.info("SCENES.CaptureInitialViewPosition", {localize: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to open the grid configuration application
   * @param {Event} event   The originating click event
   * @private
   */
  async _onGridConfig(event) {
    event.preventDefault();
    new GridConfig(this.object, this).render(true);
    return this.minimize();
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to link or unlink the scene dimensions
   * @param {Event} event
   * @returns {Promise<void>}
   * @private
   */
  async _onLinkDimensions(event) {
    event.preventDefault();
    this.linkedDimensions = !this.linkedDimensions;
    this.element.find("button.dimension-link > i").toggleClass("fa-link-simple", this.linkedDimensions);
    this.element.find("button.dimension-link > i").toggleClass("fa-link-simple-slash", !this.linkedDimensions);
    this.element.find("button.resize").attr("disabled", !this.linkedDimensions);

    // Update Tooltip
    const tooltip = game.i18n.localize(this.linkedDimensions ? "SCENES.DimensionLinked" : "SCENES.DimensionUnlinked");
    this.element.find("button.dimension-link").attr("data-tooltip", tooltip);
    game.tooltip.activate(this.element.find("button.dimension-link")[0], { text: tooltip });
  }

  /* -------------------------------------------- */

  /** @override */
  async _onChangeInput(event) {
    if ( event.target.name === "width" || event.target.name === "height" ) this._onChangeDimensions(event);
    if ( event.target.name === "environment.darknessLock" ) await this.#onDarknessLockChange(event.target.checked);
    this._previewScene(event.target.name);
    return super._onChangeInput(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle darkness lock change and update immediately the database.
   * @param {boolean} darknessLock     If the darkness lock is checked or not.
   * @returns {Promise<void>}
   */
  async #onDarknessLockChange(darknessLock) {
    const darknessLevelForm = this.form["environment.darknessLevel"];
    darknessLevelForm.disabled = darknessLock;
    await this.document.update({
      environment: {
        darknessLock,
        darknessLevel: darknessLevelForm.valueAsNumber
      }}, {render: false});
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeColorPicker(event) {
    super._onChangeColorPicker(event);
    this._previewScene(event.target.dataset.edit);
  }

  /* -------------------------------------------- */

  /** @override */
  _onChangeRange(event) {
    super._onChangeRange(event);
    for ( const target of ["base", "dark"] ) {
      if ( event.target.name === `environment.${target}.intensity` ) {
        const intensity = this.form[`environment.${target}.intensity`].valueAsNumber;
        this.form[`environment.${target}.hue`].disabled = (intensity === 0);
      }
    }
    this._previewScene(event.target.name);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onChangeTab(event, tabs, active) {
    super._onChangeTab(event, tabs, active);
    const enabled = (this._tabs[0].active === "ambience") && (this._tabs[1].active === "environment");
    this.element.find('button[type="reset"]').toggleClass("hidden", !enabled);
  }

  /* -------------------------------------------- */

  /**
   * Reset the values of the environment attributes to their default state.
   * @param {PointerEvent} event    The originating click event
   * @private
   */
  _onResetForm(event) {
    event.preventDefault();

    // Get base and dark ambience defaults and originals
    const def = Scene.cleanData().environment;
    const ori = this.document.toObject().environment;
    const defaults = {base: def.base, dark: def.dark};
    const original = {base: ori.base, dark: ori.dark};

    // Reset the elements to the default values
    for ( const target of ["base", "dark"] ) {
      this.form[`environment.${target}.hue`].disabled = (defaults[target].intensity === 0);
      this.form[`environment.${target}.intensity`].value = defaults[target].intensity;
      this.form[`environment.${target}.luminosity`].value = defaults[target].luminosity;
      this.form[`environment.${target}.saturation`].value = defaults[target].saturation;
      this.form[`environment.${target}.shadows`].value = defaults[target].shadows;
      this.form[`environment.${target}.hue`].value = defaults[target].hue;
    }

    // Update the document with the default environment values
    this.document.updateSource({environment: defaults});

    // Preview the scene and re-render the config
    this._previewScene("forceEnvironmentPreview");
    this.render();

    // Restore original environment values
    this.document.updateSource({environment: original});
  }

  /* -------------------------------------------- */

  /**
   * Live update the scene as certain properties are changed.
   * @param {string} changed  The changed property.
   * @internal
   */
  _previewScene(changed) {
    if ( !this.object.isView || !canvas.ready || !changed ) return;
    const force = changed.includes("force");

    // Preview triggered for the grid
    if ( ["grid.style", "grid.thickness", "grid.color", "grid.alpha"].includes(changed) || force ) {
      canvas.interface.grid.initializeMesh({
        style: this.form["grid.style"].value,
        thickness: Number(this.form["grid.thickness"].value),
        color: this.form["grid.color"].value,
        alpha: Number(this.form["grid.alpha"].value)
      });
    }

    // To easily track all the environment changes
    const environmentChange = changed.includes("environment.") || changed.includes("forceEnvironmentPreview") || force;

    // Preview triggered for the ambience manager
    if ( ["backgroundColor", "fog.colors.explored", "fog.colors.unexplored"].includes(changed)
      || environmentChange ) {
      canvas.environment.initialize(this.#getAmbienceFormData());
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the ambience form data.
   * @returns {Object}
   */
  #getAmbienceFormData() {
    const fd = new FormDataExtended(this.form);
    const formData = foundry.utils.expandObject(fd.object);
    return {
      backgroundColor: formData.backgroundColor,
      fogExploredColor: formData.fog.colors.explored,
      fogUnexploredColor: formData.fog.colors.unexplored,
      environment: formData.environment
    };
  }

  /* -------------------------------------------- */

  /**
   * Reset the previewed darkness level, background color, grid alpha, and grid color back to their true values.
   * @private
   */
  _resetScenePreview() {
    if ( !this.object.isView || !canvas.ready ) return;
    canvas.scene.reset();
    canvas.environment.initialize();
    canvas.interface.grid.initializeMesh(canvas.scene.grid);
  }

  /* -------------------------------------------- */

  /**
   * Handle updating the select menu of PlaylistSound options when the Playlist is changed
   * @param {Event} event   The initiating select change event
   * @private
   */
  _onChangePlaylist(event) {
    event.preventDefault();
    const playlist = game.playlists.get(event.target.value);
    const sounds = this._getDocuments(playlist?.sounds || []);
    const options = ['<option value=""></option>'].concat(sounds.map(s => {
      return `<option value="${s.id}">${s.name}</option>`;
    }));
    const select = this.form.querySelector("select[name=\"playlistSound\"]");
    select.innerHTML = options.join("");
  }

  /* -------------------------------------------- */

  /**
   * Handle updating the select menu of JournalEntryPage options when the JournalEntry is changed.
   * @param {Event} event  The initiating select change event.
   * @protected
   */
  _onChangeJournal(event) {
    event.preventDefault();
    const entry = game.journal.get(event.currentTarget.value);
    const pages = entry?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
    const options = pages.map(page => {
      const selected = (entry.id === this.object.journal?.id) && (page.id === this.object.journalEntryPage);
      return `<option value="${page.id}"${selected ? " selected" : ""}>${page.name}</option>`;
    });
    this.form.elements.journalEntryPage.innerHTML = `<option></option>${options}`;
  }

  /* -------------------------------------------- */

  /**
   * Handle updating the select menu of JournalEntryPage options when the JournalEntry is changed.
   * @param event
   * @private
   */
  _onChangeDimensions(event) {
    event.preventDefault();
    if ( !this.linkedDimensions ) return;
    const name = event.currentTarget.name;
    const value = Number(event.currentTarget.value);
    const oldValue = name === "width" ? this.object.width : this.object.height;
    const scale = value / oldValue;
    const otherInput = this.form.elements[name === "width" ? "height" : "width"];
    otherInput.value = otherInput.value * scale;

    // If new value is not a round number, display an error and revert
    if ( !Number.isInteger(parseFloat(otherInput.value)) ) {
      ui.notifications.error(game.i18n.localize("SCENES.InvalidDimension"));
      this.form.elements[name].value = oldValue;
      otherInput.value = name === "width" ? this.object.height : this.object.width;
      return;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    const scene = this.document;

    // FIXME: Ideally, FormDataExtended would know to set these fields to null instead of keeping a blank string
    // SceneData.texture.src is nullable in the schema, causing an empty string to be initialised to null. We need to
    // match that logic here to ensure that comparisons to the existing scene image are accurate.
    if ( formData["background.src"] === "" ) formData["background.src"] = null;
    if ( formData.foreground === "" ) formData.foreground = null;
    if ( formData["fog.overlay"] === "" ) formData["fog.overlay"] = null;

    // The same for fog colors
    if ( formData["fog.colors.unexplored"] === "" ) formData["fog.colors.unexplored"] = null;
    if ( formData["fog.colors.explored"] === "" ) formData["fog.colors.explored"] = null;

    // Determine what type of change has occurred
    const hasDefaultDims = (scene.background.src === null) && (scene.width === 4000) && (scene.height === 3000);
    const hasImage = formData["background.src"] || scene.background.src;
    const changedBackground =
      (formData["background.src"] !== undefined) && (formData["background.src"] !== scene.background.src);
    const clearedDims = (formData.width === null) || (formData.height === null);
    const needsThumb = changedBackground || !scene.thumb;
    const needsDims = formData["background.src"] && (clearedDims || hasDefaultDims);
    const createThumbnail = hasImage && (needsThumb || needsDims);

    // Update thumbnail and image dimensions
    if ( createThumbnail && game.settings.get("core", "noCanvas") ) {
      ui.notifications.warn("SCENES.GenerateThumbNoCanvas", {localize: true});
      formData.thumb = null;
    } else if ( createThumbnail ) {
      let td = {};
      try {
        td = await scene.createThumbnail({img: formData["background.src"] ?? scene.background.src});
      } catch(err) {
        Hooks.onError("SceneConfig#_updateObject", err, {
          msg: "Thumbnail generation for Scene failed",
          notify: "error",
          log: "error",
          scene: scene.id
        });
      }
      if ( needsThumb ) formData.thumb = td.thumb || null;
      if ( needsDims ) {
        formData.width = td.width;
        formData.height = td.height;
      }
    }

    // Warn the user if Scene dimensions are changing
    const delta = foundry.utils.diffObject(scene._source, foundry.utils.expandObject(formData));
    const changes = foundry.utils.flattenObject(delta);
    const textureChange = ["scaleX", "scaleY", "rotation"].map(k => `background.${k}`);
    if ( ["grid.size", ...textureChange].some(k => k in changes) ) {
      const confirm = await Dialog.confirm({
        title: game.i18n.localize("SCENES.DimensionChangeTitle"),
        content: `<p>${game.i18n.localize("SCENES.DimensionChangeWarning")}</p>`
      });
      if ( !confirm ) return;
    }

    // If the canvas size has changed in a nonuniform way, ask the user if they want to reposition
    let autoReposition = false;
    if ( (scene.background?.src || scene.foreground?.src) && (["width", "height", "padding", "background", "grid.size"].some(x => x in changes)) ) {
      autoReposition = true;

      // If aspect ratio changes, prompt to replace all tokens with new dimensions and warn about distortions
      let showPrompt = false;
      if ( "width" in changes && "height" in changes ) {
        const currentScale = this.object.width / this.object.height;
        const newScale = formData.width / formData.height;
        if ( currentScale !== newScale ) {
          showPrompt = true;
        }
      }
      else if ( "width" in changes || "height" in changes ) {
        showPrompt = true;
      }

      if ( showPrompt ) {
        const confirm = await Dialog.confirm({
          title: game.i18n.localize("SCENES.DistortedDimensionsTitle"),
          content: game.i18n.localize("SCENES.DistortedDimensionsWarning"),
          defaultYes: false
        });
        if ( !confirm ) autoReposition = false;
      }
    }

    // Perform the update
    delete formData["environment.darknessLock"];
    return scene.update(formData, {autoReposition});
  }
}

/**
 * Document Sheet Configuration Application
 */
class DocumentSheetConfig extends FormApplication {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["form", "sheet-config"],
      template: "templates/sheets/sheet-config.html",
      width: 400
    });
  }

  /**
   * An array of pending sheet assignments which are submitted before other elements of the framework are ready.
   * @type {object[]}
   * @private
   */
  static #pending = [];

  /* -------------------------------------------- */

  /** @inheritdoc */
  get title() {
    const name = this.object.name ?? game.i18n.localize(this.object.constructor.metadata.label);
    return `${name}: Sheet Configuration`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData(options={}) {
    const {sheetClasses, defaultClasses, defaultClass} = this.constructor.getSheetClassesForSubType(
      this.object.documentName,
      this.object.type || CONST.BASE_DOCUMENT_TYPE
    );

    // Return data
    return {
      isGM: game.user.isGM,
      object: this.object.toObject(),
      options: this.options,
      sheetClass: this.object.getFlag("core", "sheetClass") ?? "",
      blankLabel: game.i18n.localize("SHEETS.DefaultSheet"),
      sheetClasses, defaultClass, defaultClasses
    };
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {
    event.preventDefault();
    const original = this.getData({});
    const defaultSheetChanged = formData.defaultClass !== original.defaultClass;
    const documentSheetChanged = formData.sheetClass !== original.sheetClass;

    // Update world settings
    if ( game.user.isGM && defaultSheetChanged ) {
      const setting = game.settings.get("core", "sheetClasses") || {};
      const type = this.object.type || CONST.BASE_DOCUMENT_TYPE;
      foundry.utils.mergeObject(setting, {[`${this.object.documentName}.${type}`]: formData.defaultClass});
      await game.settings.set("core", "sheetClasses", setting);

      // Trigger a sheet change manually if it wouldn't be triggered by the normal ClientDocument#_onUpdate workflow.
      if ( !documentSheetChanged ) return this.object._onSheetChange({ sheetOpen: true });
    }

    // Update the document-specific override
    if ( documentSheetChanged ) return this.object.setFlag("core", "sheetClass", formData.sheetClass);
  }

  /* -------------------------------------------- */

  /**
   * Marshal information on the available sheet classes for a given document type and sub-type, and format it for
   * display.
   * @param {string} documentName  The Document type.
   * @param {string} subType       The Document sub-type.
   * @returns {{sheetClasses: object, defaultClasses: object, defaultClass: string}}
   */
  static getSheetClassesForSubType(documentName, subType) {
    const config = CONFIG[documentName];
    const defaultClasses = {};
    let defaultClass = null;
    const sheetClasses = Object.values(config.sheetClasses[subType]).reduce((obj, cfg) => {
      if ( cfg.canConfigure ) obj[cfg.id] = cfg.label;
      if ( cfg.default && !defaultClass ) defaultClass = cfg.id;
      if ( cfg.canConfigure && cfg.canBeDefault ) defaultClasses[cfg.id] = cfg.label;
      return obj;
    }, {});
    return {sheetClasses, defaultClasses, defaultClass};
  }

  /* -------------------------------------------- */
  /*  Configuration Methods
  /* -------------------------------------------- */

  /**
   * Initialize the configured Sheet preferences for Documents which support dynamic Sheet assignment
   * Create the configuration structure for supported documents
   * Process any pending sheet registrations
   * Update the default values from settings data
   */
  static initializeSheets() {
    for ( let cls of Object.values(foundry.documents) ) {
      const types = this._getDocumentTypes(cls);
      CONFIG[cls.documentName].sheetClasses = types.reduce((obj, type) => {
        obj[type] = {};
        return obj;
      }, {});
    }

    // Register any pending sheets
    this.#pending.forEach(p => {
      if ( p.action === "register" ) this.#registerSheet(p);
      else if ( p.action === "unregister" ) this.#unregisterSheet(p);
    });
    this.#pending = [];

    // Update default sheet preferences
    const defaults = game.settings.get("core", "sheetClasses");
    this.updateDefaultSheets(defaults);
  }

  /* -------------------------------------------- */

  static _getDocumentTypes(cls, types=[]) {
    if ( types.length ) return types;
    return game.documentTypes[cls.documentName];
  }

  /* -------------------------------------------- */

  /**
   * Register a sheet class as a candidate which can be used to display documents of a given type
   * @param {typeof ClientDocument} documentClass  The Document class for which to register a new Sheet option
   * @param {string} scope                         Provide a unique namespace scope for this sheet
   * @param {typeof DocumentSheet} sheetClass      A defined Application class used to render the sheet
   * @param {object} [config]                      Additional options used for sheet registration
   * @param {string|Function} [config.label]       A human-readable label for the sheet name, which will be localized
   * @param {string[]} [config.types]              An array of document types for which this sheet should be used
   * @param {boolean} [config.makeDefault=false]   Whether to make this sheet the default for provided types
   * @param {boolean} [config.canBeDefault=true]   Whether this sheet is available to be selected as a default sheet for
   *                                               all Documents of that type.
   * @param {boolean} [config.canConfigure=true]   Whether this sheet appears in the sheet configuration UI for users.
   */
  static registerSheet(documentClass, scope, sheetClass, {
    label, types, makeDefault=false, canBeDefault=true, canConfigure=true
  }={}) {
    const id = `${scope}.${sheetClass.name}`;
    const config = {documentClass, id, label, sheetClass, types, makeDefault, canBeDefault, canConfigure};
    if ( game.ready ) this.#registerSheet(config);
    else {
      config.action = "register";
      this.#pending.push(config);
    }
  }

  /**
   * Perform the sheet registration.
   * @param {object} config                               Configuration for how the sheet should be registered
   * @param {typeof ClientDocument} config.documentClass  The Document class being registered
   * @param {string} config.id                            The sheet ID being registered
   * @param {string} config.label                         The human-readable sheet label
   * @param {typeof DocumentSheet} config.sheetClass      The sheet class definition being registered
   * @param {object[]} config.types                       An array of types for which this sheet is added
   * @param {boolean} config.makeDefault                  Make this sheet the default for provided types?
   * @param {boolean} config.canBeDefault                 Whether this sheet is available to be selected as a default
   *                                                      sheet for all Documents of that type.
   * @param {boolean} config.canConfigure                 Whether the sheet appears in the sheet configuration UI for
   *                                                      users.
   */
  static #registerSheet({documentClass, id, label, sheetClass, types, makeDefault, canBeDefault, canConfigure}={}) {
    types = this._getDocumentTypes(documentClass, types);
    const classes = CONFIG[documentClass.documentName]?.sheetClasses;
    const defaults = game.ready ? game.settings.get("core", "sheetClasses") : {};
    if ( typeof classes !== "object" ) return;
    for ( const t of types ) {
      classes[t] ||= {};
      const existingDefault = defaults[documentClass.documentName]?.[t];
      const isDefault = existingDefault ? (existingDefault === id) : makeDefault;
      if ( isDefault ) Object.values(classes[t]).forEach(s => s.default = false);
      if ( label instanceof Function ) label = label();
      else if ( label ) label = game.i18n.localize(label);
      else label = id;
      classes[t][id] = {
        id, label, canBeDefault, canConfigure,
        cls: sheetClass,
        default: isDefault
      };
    }
  }

  /* -------------------------------------------- */

  /**
   * Unregister a sheet class, removing it from the list of available Applications to use for a Document type
   * @param {typeof ClientDocument} documentClass  The Document class for which to register a new Sheet option
   * @param {string} scope                         Provide a unique namespace scope for this sheet
   * @param {typeof DocumentSheet} sheetClass      A defined DocumentSheet subclass used to render the sheet
   * @param {object} [config]
   * @param {object[]} [config.types]              An Array of types for which this sheet should be removed
   */
  static unregisterSheet(documentClass, scope, sheetClass, {types}={}) {
    const id = `${scope}.${sheetClass.name}`;
    const config = {documentClass, id, types};
    if ( game.ready ) this.#unregisterSheet(config);
    else {
      config.action = "unregister";
      this.#pending.push(config);
    }
  }

  /**
   * Perform the sheet de-registration.
   * @param {object} config                               Configuration for how the sheet should be un-registered
   * @param {typeof ClientDocument} config.documentClass  The Document class being unregistered
   * @param {string} config.id                            The sheet ID being unregistered
   * @param {object[]} config.types                       An array of types for which this sheet is removed
   */
  static #unregisterSheet({documentClass, id, types}={}) {
    types = this._getDocumentTypes(documentClass, types);
    const classes = CONFIG[documentClass.documentName]?.sheetClasses;
    if ( typeof classes !== "object" ) return;
    for ( let t of types ) {
      delete classes[t][id];
    }
  }

  /* -------------------------------------------- */

  /**
   * Update the current default Sheets using a new core world setting.
   * @param {object} setting
   */
  static updateDefaultSheets(setting={}) {
    if ( !Object.keys(setting).length ) return;
    for ( let cls of Object.values(foundry.documents) ) {
      const documentName = cls.documentName;
      const cfg = CONFIG[documentName];
      const classes = cfg.sheetClasses;
      const collection = cfg.collection?.instance ?? [];
      const defaults = setting[documentName];
      if ( !defaults ) continue;

      // Update default preference for registered sheets
      for ( let [type, sheetId] of Object.entries(defaults) ) {
        const sheets = Object.values(classes[type] || {});
        let requested = sheets.find(s => s.id === sheetId);
        if ( requested ) sheets.forEach(s => s.default = s.id === sheetId);
      }

      // Close and de-register any existing sheets
      for ( let document of collection ) {
        for ( const [id, app] of Object.entries(document.apps) ) {
          app.close();
          delete document.apps[id];
        }
        document._sheet = null;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Initialize default sheet configurations for all document types.
   * @private
   */
  static _registerDefaultSheets() {
    const defaultSheets = {
      // Documents
      Actor: ActorSheet,
      Adventure: AdventureImporter,
      Folder: FolderConfig,
      Item: ItemSheet,
      JournalEntry: JournalSheet,
      Macro: MacroConfig,
      Playlist: PlaylistConfig,
      RollTable: RollTableConfig,
      Scene: SceneConfig,
      User: foundry.applications.sheets.UserConfig,
      // Embedded Documents
      ActiveEffect: ActiveEffectConfig,
      AmbientLight: foundry.applications.sheets.AmbientLightConfig,
      AmbientSound: foundry.applications.sheets.AmbientSoundConfig,
      Card: CardConfig,
      Combatant: CombatantConfig,
      Drawing: DrawingConfig,
      MeasuredTemplate: MeasuredTemplateConfig,
      Note: NoteConfig,
      PlaylistSound: PlaylistSoundConfig,
      Region: foundry.applications.sheets.RegionConfig,
      RegionBehavior: foundry.applications.sheets.RegionBehaviorConfig,
      Tile: TileConfig,
      Token: TokenConfig,
      Wall: WallConfig
    };

    Object.values(foundry.documents).forEach(base => {
      const type = base.documentName;
      const cfg = CONFIG[type];
      cfg.sheetClasses = {};
      const defaultSheet = defaultSheets[type];
      if ( !defaultSheet ) return;
      DocumentSheetConfig.registerSheet(cfg.documentClass, "core", defaultSheet, {
        makeDefault: true,
        label: () => game.i18n.format("SHEETS.DefaultDocumentSheet", {document: game.i18n.localize(`DOCUMENT.${type}`)})
      });
    });
    DocumentSheetConfig.registerSheet(Cards, "core", CardsConfig, {
      label: "CARDS.CardsDeck",
      types: ["deck"],
      makeDefault: true
    });
    DocumentSheetConfig.registerSheet(Cards, "core", CardsHand, {
      label: "CARDS.CardsHand",
      types: ["hand"],
      makeDefault: true
    });
    DocumentSheetConfig.registerSheet(Cards, "core", CardsPile, {
      label: "CARDS.CardsPile",
      types: ["pile"],
      makeDefault: true
    });
    DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalTextTinyMCESheet, {
      types: ["text"],
      label: () => game.i18n.localize("EDITOR.TinyMCE")
    });
    DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalImagePageSheet, {
      types: ["image"],
      makeDefault: true,
      label: () =>
        game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {page: game.i18n.localize("JOURNALENTRYPAGE.TypeImage")})
    });
    DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalVideoPageSheet, {
      types: ["video"],
      makeDefault: true,
      label: () =>
        game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {page: game.i18n.localize("JOURNALENTRYPAGE.TypeVideo")})
    });
    DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalPDFPageSheet, {
      types: ["pdf"],
      makeDefault: true,
      label: () =>
        game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {page: game.i18n.localize("JOURNALENTRYPAGE.TypePDF")})
    });
    DocumentSheetConfig.registerSheet(JournalEntryPage, "core", JournalTextPageSheet, {
      types: ["text"],
      makeDefault: true,
      label: () => {
        return game.i18n.format("JOURNALENTRYPAGE.DefaultPageSheet", {
          page: game.i18n.localize("JOURNALENTRYPAGE.TypeText")
        });
      }
    });
    DocumentSheetConfig.registerSheet(JournalEntryPage, "core", MarkdownJournalPageSheet, {
      types: ["text"],
      label: () => game.i18n.localize("EDITOR.Markdown")
    });
  }
}

/**
 * @typedef {Object} ChatBubbleOptions
 * @property {string[]} [cssClasses]    An optional array of CSS classes to apply to the resulting bubble
 * @property {boolean} [pan=true]       Pan to the token speaker for this bubble, if allowed by the client
 * @property {boolean} [requireVisible=false] Require that the token be visible in order for the bubble to be rendered
 */

/**
 * The Chat Bubble Class
 * This application displays a temporary message sent from a particular Token in the active Scene.
 * The message is displayed on the HUD layer just above the Token.
 */
class ChatBubbles {
  constructor() {
    this.template = "templates/hud/chat-bubble.html";

    /**
     * Track active Chat Bubbles
     * @type {object}
     */
    this.bubbles = {};

    /**
     * Track which Token was most recently panned to highlight
     * Use this to avoid repeat panning
     * @type {Token}
     * @private
     */
    this._panned = null;
  }

  /* -------------------------------------------- */

  /**
   * A reference to the chat bubbles HTML container in which rendered bubbles should live
   * @returns {jQuery}
   */
  get container() {
    return $("#chat-bubbles");
  }

  /* -------------------------------------------- */

  /**
   * Create a chat bubble message for a certain token which is synchronized for display across all connected clients.
   * @param {TokenDocument} token           The speaking Token Document
   * @param {string} message                The spoken message text
   * @param {ChatBubbleOptions} [options]   Options which affect the bubble appearance
   * @returns {Promise<jQuery|null>}        A promise which resolves with the created bubble HTML, or null
   */
  async broadcast(token, message, options={}) {
    if ( token instanceof Token ) token = token.document;
    if ( !(token instanceof TokenDocument) || !message ) {
      throw new Error("You must provide a Token instance and a message string");
    }
    game.socket.emit("chatBubble", {
      sceneId: token.parent.id,
      tokenId: token.id,
      message,
      options
    });
    return this.say(token.object, message, options);
  }

  /* -------------------------------------------- */

  /**
   * Speak a message as a particular Token, displaying it as a chat bubble
   * @param {Token} token                   The speaking Token
   * @param {string} message                The spoken message text
   * @param {ChatBubbleOptions} [options]   Options which affect the bubble appearance
   * @returns {Promise<JQuery|null>}        A Promise which resolves to the created bubble HTML element, or null
   */
  async say(token, message, {cssClasses=[], requireVisible=false, pan=true}={}) {

    // Ensure that a bubble is allowed for this token
    if ( !token || !message ) return null;
    let allowBubbles = game.settings.get("core", "chatBubbles");
    if ( !allowBubbles ) return null;
    if ( requireVisible && !token.visible ) return null;

    // Clear any existing bubble for the speaker
    await this._clearBubble(token);

    // Create the HTML and call the chatBubble hook
    const actor = ChatMessage.implementation.getSpeakerActor({scene: token.scene.id, token: token.id});
    message = await TextEditor.enrichHTML(message, { rollData: actor?.getRollData() });
    let html = $(await this._renderHTML({token, message, cssClasses: cssClasses.join(" ")}));

    const allowed = Hooks.call("chatBubble", token, html, message, {cssClasses, pan});
    if ( allowed === false ) return null;

    // Set initial dimensions
    let dimensions = this._getMessageDimensions(message);
    this._setPosition(token, html, dimensions);

    // Append to DOM
    this.container.append(html);

    // Optionally pan to the speaker
    const panToSpeaker = game.settings.get("core", "chatBubblesPan") && pan && (this._panned !== token);
    const promises = [];
    if ( panToSpeaker ) {
      const scale = Math.max(1, canvas.stage.scale.x);
      promises.push(canvas.animatePan({x: token.document.x, y: token.document.y, scale, duration: 1000}));
      this._panned = token;
    }

    // Get animation duration and settings
    const duration = this._getDuration(html);
    const scroll = dimensions.unconstrained - dimensions.height;

    // Animate the bubble
    promises.push(new Promise(resolve => {
      html.fadeIn(250, () => {
        if ( scroll > 0 ) {
          html.find(".bubble-content").animate({top: -1 * scroll}, duration - 1000, "linear", resolve);
        }
        setTimeout(() => html.fadeOut(250, () => html.remove()), duration);
      });
    }));

    // Return the chat bubble HTML after all animations have completed
    await Promise.all(promises);
    return html;
  }

  /* -------------------------------------------- */

  /**
   * Activate Socket event listeners which apply to the ChatBubbles UI.
   * @param {Socket} socket     The active web socket connection
   * @internal
   */
  static _activateSocketListeners(socket) {
    socket.on("chatBubble", ({sceneId, tokenId, message, options}) => {
      if ( !canvas.ready ) return;
      const scene = game.scenes.get(sceneId);
      if ( !scene?.isView ) return;
      const token = scene.tokens.get(tokenId);
      if ( !token ) return;
      return canvas.hud.bubbles.say(token.object, message, options);
    });
  }

  /* -------------------------------------------- */

  /**
   * Clear any existing chat bubble for a certain Token
   * @param {Token} token
   * @private
   */
  async _clearBubble(token) {
    let existing = $(`.chat-bubble[data-token-id="${token.id}"]`);
    if ( !existing.length ) return;
    return new Promise(resolve => {
      existing.fadeOut(100, () => {
        existing.remove();
        resolve();
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * Render the HTML template for the chat bubble
   * @param {object} data         Template data
   * @returns {Promise<string>}   The rendered HTML
   * @private
   */
  async _renderHTML(data) {
    return renderTemplate(this.template, data);
  }

  /* -------------------------------------------- */

  /**
   * Before displaying the chat message, determine it's constrained and unconstrained dimensions
   * @param {string} message    The message content
   * @returns {object}          The rendered message dimensions
   * @private
   */
  _getMessageDimensions(message) {
    let div = $(`<div class="chat-bubble" style="visibility:hidden">${message}</div>`);
    $("body").append(div);
    let dims = {
      width: div[0].clientWidth + 8,
      height: div[0].clientHeight
    };
    div.css({maxHeight: "none"});
    dims.unconstrained = div[0].clientHeight;
    div.remove();
    return dims;
  }

  /* -------------------------------------------- */

  /**
   * Assign styling parameters to the chat bubble, toggling either a left or right display (randomly)
   * @param {Token} token             The speaking Token
   * @param {JQuery} html             Chat bubble content
   * @param {Rectangle} dimensions    Positioning data
   * @private
   */
  _setPosition(token, html, dimensions) {
    let cls = Math.random() > 0.5 ? "left" : "right";
    html.addClass(cls);
    const pos = {
      height: dimensions.height,
      width: dimensions.width,
      top: token.y - dimensions.height - 8
    };
    if ( cls === "right" ) pos.left = token.x - (dimensions.width - token.w);
    else pos.left = token.x;
    html.css(pos);
  }

  /* -------------------------------------------- */

  /**
   * Determine the length of time for which to display a chat bubble.
   * Research suggests that average reading speed is 200 words per minute.
   * Since these are short-form messages, we multiply reading speed by 1.5.
   * Clamp the result between 1 second (minimum) and 20 seconds (maximum)
   * @param {jQuery} html     The HTML message
   * @returns {number}        The number of milliseconds for which to display the message
   */
  _getDuration(html) {
    const words = html.text().split(/\s+/).reduce((n, w) => n + Number(!!w.trim().length), 0);
    const ms = (words * 60 * 1000) / 300;
    return Math.clamp(1000, ms, 20000);
  }
}

/**
 * The Heads-Up Display is a canvas-sized Application which renders HTML overtop of the game canvas.
 */
class HeadsUpDisplay extends Application {

  /**
   * Token HUD
   * @type {TokenHUD}
   */
  token = new CONFIG.Token.hudClass();

  /**
   * Tile HUD
   * @type {TileHUD}
   */
  tile = new CONFIG.Tile.hudClass();

  /**
   * Drawing HUD
   * @type {DrawingHUD}
   */
  drawing = new CONFIG.Drawing.hudClass();

  /**
   * Chat Bubbles
   * @type {ChatBubbles}
   */
  bubbles = new CONFIG.Canvas.chatBubblesClass();

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.id = "hud";
    options.template = "templates/hud/hud.html";
    options.popOut = false;
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    if ( !canvas.ready ) return {};
    return {
      width: canvas.dimensions.width,
      height: canvas.dimensions.height
    };
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _render(force, options) {
    await super._render(force, options);
    this.align();
  }

  /* -------------------------------------------- */

  /**
   * Align the position of the HUD layer to the current position of the canvas
   */
  align() {
    const hud = this.element[0];
    const {x, y} = canvas.primary.getGlobalPosition();
    const scale = canvas.stage.scale.x;
    hud.style.left = `${x}px`;
    hud.style.top = `${y}px`;
    hud.style.transform = `scale(${scale})`;
  }
}

/**
 * @typedef {Object} SceneControlTool
 * @property {string} name
 * @property {string} title
 * @property {string} icon
 * @property {boolean} visible
 * @property {boolean} toggle
 * @property {boolean} active
 * @property {boolean} button
 * @property {Function} onClick
 * @property {ToolclipConfiguration} toolclip  Configuration for rendering the tool's toolclip.
 */

/**
 * @typedef {Object} SceneControl
 * @property {string} name
 * @property {string} title
 * @property {string} layer
 * @property {string} icon
 * @property {boolean} visible
 * @property {SceneControlTool[]} tools
 * @property {string} activeTool
 */

/**
 * @typedef {object} ToolclipConfiguration
 * @property {string} src                         The filename of the toolclip video.
 * @property {string} heading                     The heading string.
 * @property {ToolclipConfigurationItem[]} items  The items in the toolclip body.
 */

/**
 * @typedef {object} ToolclipConfigurationItem
 * @property {string} [paragraph]  A plain paragraph of content for this item.
 * @property {string} [heading]    A heading for the item.
 * @property {string} [content]    Content for the item.
 * @property {string} [reference]  If the item is a single key reference, use this instead of content.
 */

/**
 * Scene controls navigation menu
 * @extends {Application}
 */
class SceneControls extends Application {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      width: 100,
      id: "controls",
      template: "templates/hud/controls.html",
      popOut: false
    });
  }

  /* -------------------------------------------- */

  /**
   * The Array of Scene Control buttons which are currently rendered
   * @type {SceneControl[]}
   */
  controls = this._getControlButtons();

  /* -------------------------------------------- */

  /**
   * The currently active control set
   * @type {string}
   */
  get activeControl() {
    return this.#control;
  }

  #control = "token";

  /* -------------------------------------------- */

  /**
   * The currently active tool in the control palette
   * @type {string}
   */
  get activeTool() {
    return this.#tools[this.#control];
  }

  /**
   * Track which tool is active within each control set
   * @type {Record<string, string>}
   */
  #tools = {};

  /* -------------------------------------------- */

  /**
   * Return the active control set
   * @type {SceneControl|null}
   */
  get control() {
    if ( !this.controls ) return null;
    return this.controls.find(c => c.name === this.#control) || null;
  }

  /* -------------------------------------------- */

  /**
   * Return the actively controlled tool
   * @type {SceneControlTool|null}
   */
  get tool() {
    const control = this.control;
    if ( !control ) return null;
    return this.#tools[control.name] || null;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Initialize the Scene Controls by obtaining the set of control buttons and rendering the HTML
   * @param {object} options      Options which modify how the controls UI is initialized
   * @param {string} [options.control]      An optional control set to set as active
   * @param {string} [options.layer]        An optional layer name to target as the active control
   * @param {string} [options.tool]         A specific named tool to set as active for the palette
   */
  initialize({control, layer, tool} = {}) {

    // Determine the control set to activate
    let controlSet = control ? this.controls.find(c => c.name === control) : null;
    if ( !controlSet && layer ) controlSet = this.controls.find(c => c.layer === layer);
    if ( !controlSet ) controlSet = this.control;

    // Determine the tool to activate
    tool ||= this.#tools[controlSet.name] || controlSet.activeTool || null;

    // Activate the new control scheme
    this.#control = controlSet?.name || null;
    if ( controlSet && (this.#tools[this.#control] !== tool) ) {
      this.#tools[this.#control] = tool;

      // Refresh placeable states if the active tool changed
      if ( canvas.ready ) {
        // TODO: Perhaps replace this with a CanvasLayer#_onToolChanged callback
        const layer = canvas[controlSet.layer];
        if ( layer instanceof PlaceablesLayer ) {
          for ( const placeable of layer.placeables ) placeable.renderFlags.set({refreshState: true});
        }
      }
    }
    this.controls = this._getControlButtons();

    // Render the UI
    this.render(true);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async getData(options={}) {
    const showToolclips = game.settings.get("core", "showToolclips");
    const canvasActive = !!canvas.scene;
    const controls = [];
    const isMac = navigator.appVersion.includes("Mac");
    const mod = isMac ? "⌘" : game.i18n.localize("CONTROLS.CtrlAbbr");
    const alt = isMac ? "⌥" : game.i18n.localize("CONTROLS.Alt");

    for ( const c of this.controls ) {
      if ( c.visible === false ) continue;
      const control = foundry.utils.deepClone(c);
      control.isActive = canvasActive && (this.#control === control.name);
      control.css = control.isActive ? "active" : "";
      control.tools = [];

      for ( const t of c.tools ) {
        if ( t.visible === false ) continue;
        const tool = foundry.utils.deepClone(t);
        tool.isActive = canvasActive && ((this.#tools[control.name] === tool.name) || (tool.toggle && tool.active));
        tool.css = [
          tool.toggle ? "toggle" : null,
          tool.isActive ? "active" : null
        ].filterJoin(" ");
        tool.tooltip = showToolclips && tool.toolclip
          ? await renderTemplate("templates/hud/toolclip.html", { ...tool.toolclip, alt, mod })
          : tool.title;
        control.tools.push(tool);
      }

      if ( control.tools.length ) controls.push(control);
    }

    // Return data for rendering
    return {
      controls,
      active: canvasActive,
      cssClass: canvasActive ? "" : "disabled"
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    html.find(".scene-control").click(this._onClickLayer.bind(this));
    html.find(".control-tool").click(this._onClickTool.bind(this));
    canvas.notes?.hintMapNotes();
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on a Control set
   * @param {Event} event   A click event on a tool control
   * @private
   */
  _onClickLayer(event) {
    event.preventDefault();
    if ( !canvas.ready ) return;
    const li = event.currentTarget;
    const controlName = li.dataset.control;
    if ( this.#control === controlName ) return;
    this.#control = controlName;
    const control = this.controls.find(c => c.name === controlName);
    if ( control ) canvas[control.layer].activate();
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on Tool controls
   * @param {Event} event   A click event on a tool control
   * @private
   */
  _onClickTool(event) {
    event.preventDefault();
    if ( !canvas.ready ) return;
    const li = event.currentTarget;
    const control = this.control;
    const toolName = li.dataset.tool;
    const tool = control.tools.find(t => t.name === toolName);

    // Handle Toggles
    if ( tool.toggle ) {
      tool.active = !tool.active;
      if ( tool.onClick instanceof Function ) tool.onClick(tool.active);
    }

    // Handle Buttons
    else if ( tool.button ) {
      if ( tool.onClick instanceof Function ) tool.onClick();
    }

    // Handle Tools
    else if ( this.#tools[control.name] !== toolName ) {
      this.#tools[control.name] = toolName;
      const layer = canvas[control.layer];
      if ( layer instanceof PlaceablesLayer ) {
        for ( const placeable of layer.placeables ) placeable.renderFlags.set({refreshState: true});
      }
      if ( tool.onClick instanceof Function ) tool.onClick();
    }

    // Render the controls
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Get the set of Control sets and tools that are rendered as the Scene Controls.
   * These controls may be extended using the "getSceneControlButtons" Hook.
   * @returns {SceneControl[]}
   * @private
   */
  _getControlButtons() {
    const controls = [];
    const isGM = game.user.isGM;
    const clip = game.settings.get("core", "showToolclips") ? "Clip" : "";
    const commonControls = {
      create: { heading: "CONTROLS.CommonCreate", reference: "CONTROLS.ClickDrag" },
      move: { heading: "CONTROLS.CommonMove", reference: "CONTROLS.Drag" },
      edit: { heading: "CONTROLS.CommonEdit", reference: "CONTROLS.DoubleClick" },
      editAlt: { heading: "CONTROLS.CommonEdit", reference: "CONTROLS.DoubleRightClick" },
      sheet: { heading: "CONTROLS.CommonOpenSheet", reference: "CONTROLS.DoubleClick" },
      hide: { heading: "CONTROLS.CommonHide", reference: "CONTROLS.RightClick" },
      delete: { heading: "CONTROLS.CommonDelete", reference: "CONTROLS.Delete" },
      rotate: { heading: "CONTROLS.CommonRotate", content: "CONTROLS.ShiftOrCtrlScroll" },
      select: { heading: "CONTROLS.CommonSelect", reference: "CONTROLS.Click" },
      selectAlt: { heading: "CONTROLS.CommonSelect", content: "CONTROLS.ClickOrClickDrag" },
      selectMultiple: { heading: "CONTROLS.CommonSelectMultiple", reference: "CONTROLS.ShiftClick" },
      hud: { heading: "CONTROLS.CommonToggleHUD", reference: "CONTROLS.RightClick" },
      draw: { heading: "CONTROLS.CommonDraw", reference: "CONTROLS.ClickDrag" },
      drawProportionally: { heading: "CONTROLS.CommonDrawProportional", reference: "CONTROLS.AltClickDrag" },
      place: { heading: "CONTROLS.CommonPlace", reference: "CONTROLS.ClickDrag" },
      chain: { heading: "CONTROLS.CommonChain", content: "CONTROLS.ChainCtrlClick" },
      movePoint: { heading: "CONTROLS.CommonMovePoint", reference: "CONTROLS.ClickDrag" },
      openClose: { heading: "CONTROLS.CommonOpenClose", reference: "CONTROLS.Click" },
      openCloseSilently: { heading: "CONTROLS.CommonOpenCloseSilently", reference: "CONTROLS.AltClick" },
      lock: { heading: "CONTROLS.CommonLock", reference: "CONTROLS.RightClick" },
      lockSilently: { heading: "CONTROLS.CommonLockSilently", reference: "CONTROLS.AltRightClick" },
      onOff: { heading: "CONTROLS.CommonOnOff", reference: "CONTROLS.RightClick" }
    };

    const buildItems = (...items) => items.map(item => commonControls[item]);

    // Token Controls
    controls.push({
      name: "token",
      title: "CONTROLS.GroupToken",
      layer: "tokens",
      icon: "fa-solid fa-user-alt",
      tools: [
        {
          name: "select",
          title: "CONTROLS.BasicSelect",
          icon: "fa-solid fa-expand",
          toolclip: {
            src: "toolclips/tools/token-select.webm",
            heading: "CONTROLS.BasicSelect",
            items: [
              { paragraph: "CONTROLS.BasicSelectP" },
              ...buildItems("selectAlt", "selectMultiple", "move", "rotate", "hud", "sheet"),
              ...(game.user.isGM ? buildItems("editAlt", "delete") : []),
              { heading: "CONTROLS.BasicMeasureStart", reference: "CONTROLS.CtrlClickDrag" },
              { heading: "CONTROLS.BasicMeasureWaypoints", reference: "CONTROLS.CtrlClick" },
              { heading: "CONTROLS.BasicMeasureFollow", reference: "CONTROLS.Spacebar" }
            ]
          }
        },
        {
          name: "target",
          title: "CONTROLS.TargetSelect",
          icon: "fa-solid fa-bullseye",
          toolclip: {
            src: "toolclips/tools/token-target.webm",
            heading: "CONTROLS.TargetSelect",
            items: [
              { paragraph: "CONTROLS.TargetSelectP" },
              ...buildItems("selectAlt", "selectMultiple")
            ]
          }
        },
        {
          name: "ruler",
          title: "CONTROLS.BasicMeasure",
          icon: "fa-solid fa-ruler",
          toolclip: {
            src: "toolclips/tools/token-measure.webm",
            heading: "CONTROLS.BasicMeasure",
            items: [
              { heading: "CONTROLS.BasicMeasureStart", reference: "CONTROLS.ClickDrag" },
              { heading: "CONTROLS.BasicMeasureWaypoints", reference: "CONTROLS.CtrlClick" },
              { heading: "CONTROLS.BasicMeasureFollow", reference: "CONTROLS.Spacebar" }
            ]
          }
        }
      ],
      activeTool: "select"
    });

    // Measurement Layer Tools
    controls.push({
      name: "measure",
      title: "CONTROLS.GroupMeasure",
      layer: "templates",
      icon: "fa-solid fa-ruler-combined",
      visible: game.user.can("TEMPLATE_CREATE"),
      tools: [
        {
          name: "circle",
          title: "CONTROLS.MeasureCircle",
          icon: "fa-regular fa-circle",
          toolclip: {
            src: "toolclips/tools/measure-circle.webm",
            heading: "CONTROLS.MeasureCircle",
            items: buildItems("create", "move", "edit", "hide", "delete")
          }
        },
        {
          name: "cone",
          title: "CONTROLS.MeasureCone",
          icon: "fa-solid fa-angle-left",
          toolclip: {
            src: "toolclips/tools/measure-cone.webm",
            heading: "CONTROLS.MeasureCone",
            items: buildItems("create", "move", "edit", "hide", "delete", "rotate")
          }
        },
        {
          name: "rect",
          title: "CONTROLS.MeasureRect",
          icon: "fa-regular fa-square",
          toolclip: {
            src: "toolclips/tools/measure-rect.webm",
            heading: "CONTROLS.MeasureRect",
            items: buildItems("create", "move", "edit", "hide", "delete", "rotate")
          }
        },
        {
          name: "ray",
          title: "CONTROLS.MeasureRay",
          icon: "fa-solid fa-arrows-alt-v",
          toolclip: {
            src: "toolclips/tools/measure-ray.webm",
            heading: "CONTROLS.MeasureRay",
            items: buildItems("create", "move", "edit", "hide", "delete", "rotate")
          }
        },
        {
          name: "clear",
          title: "CONTROLS.MeasureClear",
          icon: "fa-solid fa-trash",
          visible: isGM,
          onClick: () => canvas.templates.deleteAll(),
          button: true
        }
      ],
      activeTool: "circle"
    });

    // Tiles Layer
    controls.push({
      name: "tiles",
      title: "CONTROLS.GroupTile",
      layer: "tiles",
      icon: "fa-solid fa-cubes",
      visible: isGM,
      tools: [
        {
          name: "select",
          title: "CONTROLS.TileSelect",
          icon: "fa-solid fa-expand",
          toolclip: {
            src: "toolclips/tools/tile-select.webm",
            heading: "CONTROLS.TileSelect",
            items: buildItems("selectAlt", "selectMultiple", "move", "rotate", "hud", "edit", "delete")
          }
        },
        {
          name: "tile",
          title: "CONTROLS.TilePlace",
          icon: "fa-solid fa-cube",
          toolclip: {
            src: "toolclips/tools/tile-place.webm",
            heading: "CONTROLS.TilePlace",
            items: buildItems("create", "move", "rotate", "hud", "edit", "delete")
          }
        },
        {
          name: "browse",
          title: "CONTROLS.TileBrowser",
          icon: "fa-solid fa-folder",
          button: true,
          onClick: () => {
            new FilePicker({
              type: "imagevideo",
              displayMode: "tiles",
              tileSize: true
            }).render(true);
          },
          toolclip: {
            src: "toolclips/tools/tile-browser.webm",
            heading: "CONTROLS.TileBrowser",
            items: buildItems("place", "move", "rotate", "hud", "edit", "delete")
          }
        },
        {
          name: "foreground",
          title: "CONTROLS.TileForeground",
          icon: "fa-solid fa-home",
          toggle: true,
          active: false,
          onClick: active => {
            this.control.foreground = active;
            for ( const tile of canvas.tiles.placeables ) {
              tile.renderFlags.set({refreshState: true});
              if ( tile.controlled ) tile.release();
            }
          }
        },
        {
          name: "snap",
          title: "CONTROLS.CommonForceSnap",
          icon: "fa-solid fa-plus",
          toggle: true,
          active: canvas.forceSnapVertices,
          onClick: toggled => canvas.forceSnapVertices = toggled
        }
      ],
      activeTool: "select"
    });

    // Drawing Tools
    controls.push({
      name: "drawings",
      title: "CONTROLS.GroupDrawing",
      layer: "drawings",
      icon: "fa-solid fa-pencil-alt",
      visible: game.user.can("DRAWING_CREATE"),
      tools: [
        {
          name: "select",
          title: "CONTROLS.DrawingSelect",
          icon: "fa-solid fa-expand",
          toolclip: {
            src: "toolclips/tools/drawing-select.webm",
            heading: "CONTROLS.DrawingSelect",
            items: buildItems("selectAlt", "selectMultiple", "move", "hud", "edit", "delete", "rotate")
          }
        },
        {
          name: "rect",
          title: "CONTROLS.DrawingRect",
          icon: "fa-solid fa-square",
          toolclip: {
            src: "toolclips/tools/drawing-rect.webm",
            heading: "CONTROLS.DrawingRect",
            items: buildItems("draw", "move", "hud", "edit", "delete", "rotate")
          }
        },
        {
          name: "ellipse",
          title: "CONTROLS.DrawingEllipse",
          icon: "fa-solid fa-circle",
          toolclip: {
            src: "toolclips/tools/drawing-ellipse.webm",
            heading: "CONTROLS.DrawingEllipse",
            items: buildItems("draw", "move", "hud", "edit", "delete", "rotate")
          }
        },
        {
          name: "polygon",
          title: "CONTROLS.DrawingPoly",
          icon: "fa-solid fa-draw-polygon",
          toolclip: {
            src: "toolclips/tools/drawing-polygon.webm",
            heading: "CONTROLS.DrawingPoly",
            items: [
              { heading: "CONTROLS.CommonDraw", content: "CONTROLS.DrawingPolyP" },
              ...buildItems("move", "hud", "edit", "delete", "rotate")
            ]
          }
        },
        {
          name: "freehand",
          title: "CONTROLS.DrawingFree",
          icon: "fa-solid fa-signature",
          toolclip: {
            src: "toolclips/tools/drawing-free.webm",
            heading: "CONTROLS.DrawingFree",
            items: buildItems("draw", "move", "hud", "edit", "delete", "rotate")
          }
        },
        {
          name: "text",
          title: "CONTROLS.DrawingText",
          icon: "fa-solid fa-font",
          onClick: () => {
            const controlled = canvas.drawings.controlled;
            if ( controlled.length === 1 ) controlled[0].enableTextEditing();
          },
          toolclip: {
            src: "toolclips/tools/drawing-text.webm",
            heading: "CONTROLS.DrawingText",
            items: buildItems("draw", "move", "hud", "edit", "delete", "rotate")
          }
        },
        {
          name: "role",
          title: "CONTROLS.DrawingRole",
          icon: "fa-solid fa-circle-info",
          toggle: true,
          active: false
        },
        {
          name: "snap",
          title: "CONTROLS.CommonForceSnap",
          icon: "fa-solid fa-plus",
          toggle: true,
          active: canvas.forceSnapVertices,
          onClick: toggled => canvas.forceSnapVertices = toggled
        },
        {
          name: "configure",
          title: "CONTROLS.DrawingConfig",
          icon: "fa-solid fa-cog",
          onClick: () => canvas.drawings.configureDefault(),
          button: true
        },
        {
          name: "clear",
          title: "CONTROLS.DrawingClear",
          icon: "fa-solid fa-trash",
          visible: isGM,
          onClick: () => canvas.drawings.deleteAll(),
          button: true
        }
      ],
      activeTool: "select"
    });

    // Walls Layer Tools
    controls.push({
      name: "walls",
      title: "CONTROLS.GroupWall",
      layer: "walls",
      icon: "fa-solid fa-block-brick",
      visible: isGM,
      tools: [
        {
          name: "select",
          title: "CONTROLS.WallSelect",
          icon: "fa-solid fa-expand",
          toolclip: {
            src: "toolclips/tools/wall-select.webm",
            heading: "CONTROLS.WallSelect",
            items: [
              ...buildItems("selectAlt", "selectMultiple", "move"),
              { heading: "CONTROLS.CommonMoveWithoutSnapping", reference: "CONTROLS.ShiftDrag" },
              { heading: "CONTROLS.CommonEdit", content: "CONTROLS.WallSelectEdit" },
              ...buildItems("delete")
            ]
          }
        },
        {
          name: "walls",
          title: "CONTROLS.WallDraw",
          icon: "fa-solid fa-bars",
          toolclip: {
            src: "toolclips/tools/wall-basic.webm",
            heading: "CONTROLS.WallBasic",
            items: [
              { heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallBasicBlocks" },
              ...buildItems("place", "chain", "movePoint", "edit", "delete")
            ]
          }
        },
        {
          name: "terrain",
          title: "CONTROLS.WallTerrain",
          icon: "fa-solid fa-mountain",
          toolclip: {
            src: "toolclips/tools/wall-terrain.webm",
            heading: "CONTROLS.WallTerrain",
            items: [
              { heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallTerrainBlocks" },
              ...buildItems("place", "chain", "movePoint", "edit", "delete")
            ]
          }
        },
        {
          name: "invisible",
          title: "CONTROLS.WallInvisible",
          icon: "fa-solid fa-eye-slash",
          toolclip: {
            src: "toolclips/tools/wall-invisible.webm",
            heading: "CONTROLS.WallInvisible",
            items: [
              { heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallInvisibleBlocks" },
              ...buildItems("place", "chain", "movePoint", "edit", "delete")
            ]
          }
        },
        {
          name: "ethereal",
          title: "CONTROLS.WallEthereal",
          icon: "fa-solid fa-mask",
          toolclip: {
            src: "toolclips/tools/wall-ethereal.webm",
            heading: "CONTROLS.WallEthereal",
            items: [
              { heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallEtherealBlocks" },
              ...buildItems("place", "chain", "movePoint", "edit", "delete")
            ]
          }
        },
        {
          name: "doors",
          title: "CONTROLS.WallDoors",
          icon: "fa-solid fa-door-open",
          toolclip: {
            src: "toolclips/tools/wall-door.webm",
            heading: "CONTROLS.WallDoors",
            items: [
              { heading: "CONTROLS.CommonBlocks", content: "CONTROLS.DoorBlocks" },
              ...buildItems("openClose", "openCloseSilently", "lock", "lockSilently", "place", "chain", "movePoint", "edit")
            ]
          }
        },
        {
          name: "secret",
          title: "CONTROLS.WallSecret",
          icon: "fa-solid fa-user-secret",
          toolclip: {
            src: "toolclips/tools/wall-secret-door.webm",
            heading: "CONTROLS.WallSecret",
            items: [
              { heading: "CONTROLS.WallSecretHidden", content: "CONTROLS.WallSecretHiddenP" },
              { heading: "CONTROLS.CommonBlocks", content: "CONTROLS.DoorBlocks" },
              ...buildItems("openClose", "openCloseSilently", "lock", "lockSilently", "place", "chain", "movePoint", "edit")
            ]
          }
        },
        {
          name: "window",
          title: "CONTROLS.WallWindow",
          icon: "fa-solid fa-window-frame",
          toolclip: {
            src: "toolclips/tools/wall-window.webm",
            heading: "CONTROLS.WallWindow",
            items: [
              { heading: "CONTROLS.CommonBlocks", content: "CONTROLS.WallWindowBlocks" },
              ...buildItems("place", "chain", "movePoint", "edit", "delete")
            ]
          }
        },
        {
          name: "clone",
          title: "CONTROLS.WallClone",
          icon: "fa-regular fa-clone"
        },
        {
          name: "snap",
          title: "CONTROLS.CommonForceSnap",
          icon: "fa-solid fa-plus",
          toggle: true,
          active: canvas.forceSnapVertices,
          onClick: toggled => canvas.forceSnapVertices = toggled,
          toolclip: {
            src: "toolclips/tools/wall-snap.webm",
            heading: "CONTROLS.CommonForceSnap",
            items: [{ paragraph: "CONTROLS.WallSnapP" }]
          }
        },
        {
          name: "close-doors",
          title: "CONTROLS.WallCloseDoors",
          icon: "fa-regular fa-door-closed",
          onClick: () => {
            let updates = canvas.walls.placeables.reduce((arr, w) => {
              if ( w.isDoor && (w.document.ds === CONST.WALL_DOOR_STATES.OPEN) ) {
                arr.push({_id: w.id, ds: CONST.WALL_DOOR_STATES.CLOSED});
              }
              return arr;
            }, []);
            if ( !updates.length ) return;
            canvas.scene.updateEmbeddedDocuments("Wall", updates, {sound: false});
            ui.notifications.info(game.i18n.format("CONTROLS.WallDoorsClosed", {number: updates.length}));
          }
        },
        {
          name: "clear",
          title: "CONTROLS.WallClear",
          icon: "fa-solid fa-trash",
          onClick: () => canvas.walls.deleteAll(),
          button: true
        }
      ],
      activeTool: "walls"
    });

    // Lighting Layer Tools
    controls.push({
      name: "lighting",
      title: "CONTROLS.GroupLighting",
      layer: "lighting",
      icon: "fa-regular fa-lightbulb",
      visible: isGM,
      tools: [
        {
          name: "light",
          title: "CONTROLS.LightDraw",
          icon: "fa-solid fa-lightbulb",
          toolclip: {
            src: "toolclips/tools/light-draw.webm",
            heading: "CONTROLS.LightDraw",
            items: buildItems("create", "edit", "rotate", "onOff")
          }
        },
        {
          name: "day",
          title: "CONTROLS.LightDay",
          icon: "fa-solid fa-sun",
          visible: !canvas.scene?.environment.darknessLock,
          onClick: () => canvas.scene.update(
            {environment: {darknessLevel: 0.0}},
            {animateDarkness: CONFIG.Canvas.darknessToDaylightAnimationMS}
          ),
          button: true,
          toolclip: {
            src: "toolclips/tools/light-day.webm",
            heading: "CONTROLS.LightDay",
            items: [
              {heading: "CONTROLS.MakeDayH", content: "CONTROLS.MakeDayP"},
              {heading: "CONTROLS.AutoLightToggleH", content: "CONTROLS.AutoLightToggleP"}
            ]
          }
        },
        {
          name: "night",
          title: "CONTROLS.LightNight",
          icon: "fa-solid fa-moon",
          visible: !canvas.scene?.environment.darknessLock,
          onClick: () => canvas.scene.update(
            {environment: {darknessLevel: 1.0}},
            {animateDarkness: CONFIG.Canvas.daylightToDarknessAnimationMS}
          ),
          button: true,
          toolclip: {
            src: "toolclips/tools/light-night.webm",
            heading: "CONTROLS.LightNight",
            items: [
              {heading: "CONTROLS.MakeNightH", content: "CONTROLS.MakeNightP"},
              {heading: "CONTROLS.AutoLightToggleH", content: "CONTROLS.AutoLightToggleP"}
            ]
          }
        },
        {
          name: "reset",
          title: "CONTROLS.LightReset",
          icon: "fa-solid fa-cloud",
          onClick: () => {
            new Dialog({
              title: game.i18n.localize("CONTROLS.FOWResetTitle"),
              content: `<p>${game.i18n.localize("CONTROLS.FOWResetDesc")}</p>`,
              buttons: {
                yes: {
                  icon: '<i class="fa-solid fa-check"></i>',
                  label: "Yes",
                  callback: () => canvas.fog.reset()
                },
                no: {
                  icon: '<i class="fa-solid fa-times"></i>',
                  label: "No"
                }
              }
            }).render(true);
          },
          button: true,
          toolclip: {
            src: "toolclips/tools/light-reset.webm",
            heading: "CONTROLS.LightReset",
            items: [{ paragraph: "CONTROLS.LightResetP" }]
          }
        },
        {
          name: "clear",
          title: "CONTROLS.LightClear",
          icon: "fa-solid fa-trash",
          onClick: () => canvas.lighting.deleteAll(),
          button: true
        }
      ],
      activeTool: "light"
    });

    // Sounds Layer Tools
    controls.push({
      name: "sounds",
      title: "CONTROLS.GroupSound",
      layer: "sounds",
      icon: "fa-solid fa-music",
      visible: isGM,
      tools: [
        {
          name: "sound",
          title: "CONTROLS.SoundDraw",
          icon: "fa-solid fa-volume-up",
          toolclip: {
            src: "toolclips/tools/sound-draw.webm",
            heading: "CONTROLS.SoundDraw",
            items: buildItems("create", "edit", "rotate", "onOff")
          }
        },
        {
          name: "preview",
          title: `CONTROLS.SoundPreview${clip}`,
          icon: "fa-solid fa-headphones",
          toggle: true,
          active: canvas.sounds?.livePreview ?? false,
          onClick: toggled => {
            canvas.sounds.livePreview = toggled;
            canvas.sounds.refresh();
          },
          toolclip: {
            src: "toolclips/tools/sound-preview.webm",
            heading: "CONTROLS.SoundPreview",
            items: [{ paragraph: "CONTROLS.SoundPreviewP" }]
          }
        },
        {
          name: "clear",
          title: "CONTROLS.SoundClear",
          icon: "fa-solid fa-trash",
          onClick: () => canvas.sounds.deleteAll(),
          button: true
        }
      ],
      activeTool: "sound"
    });

    // Regions Layer Tools
    controls.push({
      name: "regions",
      title: "CONTROLS.GroupRegion",
      layer: "regions",
      icon: "fa-regular fa-game-board",
      visible: game.user.isGM,
      tools: [
        {
          name: "select",
          title: "CONTROLS.RegionSelect",
          icon: "fa-solid fa-expand",
          toolclip: {
            src: "toolclips/tools/region-select.webm",
            heading: "CONTROLS.RegionSelect",
            items: [
              { paragraph: "CONTROLS.RegionSelectP" },
              ...buildItems("selectAlt", "selectMultiple", "edit", "delete")
            ]
          }
        },
        {
          name: "rectangle",
          title: "CONTROLS.RegionRectangle",
          icon: "fa-solid fa-square",
          toolclip: {
            src: "toolclips/tools/region-rectangle.webm",
            heading: "CONTROLS.RegionRectangle",
            items: [
              { paragraph: "CONTROLS.RegionShape" },
              ...buildItems("draw", "drawProportionally"),
              { paragraph: "CONTROLS.RegionPerformance" },
            ]
          }
        },
        {
          name: "ellipse",
          title: "CONTROLS.RegionEllipse",
          icon: "fa-solid fa-circle",
          toolclip: {
            src: "toolclips/tools/region-ellipse.webm",
            heading: "CONTROLS.RegionEllipse",
            items: [
              { paragraph: "CONTROLS.RegionShape" },
              ...buildItems("draw", "drawProportionally"),
              { paragraph: "CONTROLS.RegionPerformance" },
            ]
          }
        },
        {
          name: "polygon",
          title: "CONTROLS.RegionPolygon",
          icon: "fa-solid fa-draw-polygon",
          toolclip: {
            src: "toolclips/tools/region-polygon.webm",
            heading: "CONTROLS.RegionPolygon",
            items: [
              { paragraph: "CONTROLS.RegionShape" },
              ...buildItems("draw", "drawProportionally"),
              { paragraph: "CONTROLS.RegionPerformance" },
            ]
          }
        },
        {
          name: "hole",
          title: "CONTROLS.RegionHole",
          icon: "fa-duotone fa-object-subtract",
          toggle: true,
          active: canvas.regions?._holeMode ?? false,
          onClick: toggled => canvas.regions._holeMode = toggled,
          toolclip: {
            src: "toolclips/tools/region-hole.webm",
            heading: "CONTROLS.RegionHole",
            items: [
              { paragraph: "CONTROLS.RegionHoleP" },
              ...buildItems("draw", "drawProportionally")
            ]
          }
        },
        {
          name: "snap",
          title: "CONTROLS.CommonForceSnap",
          icon: "fa-solid fa-plus",
          toggle: true,
          active: canvas.forceSnapVertices,
          onClick: toggled => canvas.forceSnapVertices = toggled,
          toolclip: {
            src: "toolclips/tools/region-snap.webm",
            heading: "CONTROLS.CommonForceSnap",
            items: [
              { paragraph: "CONTROLS.RegionSnap" },
              ...buildItems("draw", "drawProportionally")
            ]
          }
        },
        {
          name: "clear",
          title: "CONTROLS.RegionClear",
          icon: "fa-solid fa-trash",
          onClick: () => canvas.regions.deleteAll(),
          button: true
        }
      ],
      activeTool: "select"
    });

    // Notes Layer Tools
    controls.push({
      name: "notes",
      title: "CONTROLS.GroupNotes",
      layer: "notes",
      icon: "fa-solid fa-bookmark",
      tools: [
        {
          name: "select",
          title: "CONTROLS.NoteSelect",
          icon: "fa-solid fa-expand"
        },
        {
          name: "journal",
          title: "NOTE.Create",
          visible: game.user.hasPermission("NOTE_CREATE"),
          icon: CONFIG.JournalEntry.sidebarIcon
        },
        {
          name: "toggle",
          title: "CONTROLS.NoteToggle",
          icon: "fa-solid fa-map-pin",
          toggle: true,
          active: game.settings.get("core", NotesLayer.TOGGLE_SETTING),
          onClick: toggled => game.settings.set("core", NotesLayer.TOGGLE_SETTING, toggled)
        },
        {
          name: "clear",
          title: "CONTROLS.NoteClear",
          icon: "fa-solid fa-trash",
          visible: isGM,
          onClick: () => canvas.notes.deleteAll(),
          button: true
        }
      ],
      activeTool: "select"
    });

    // Pass the Scene Controls to a hook function to allow overrides or changes
    Hooks.callAll("getSceneControlButtons", controls);
    return controls;
  }
}

/**
 * The global action bar displayed at the bottom of the game view.
 * The Hotbar is a UI element at the bottom of the screen which contains Macros as interactive buttons.
 * The Hotbar supports 5 pages of global macros which can be dragged and dropped to organize as you wish.
 *
 * Left-clicking a Macro button triggers its effect.
 * Right-clicking the button displays a context menu of Macro options.
 * The number keys 1 through 0 activate numbered hotbar slots.
 * Pressing the delete key while hovering over a Macro will remove it from the bar.
 *
 * @see {@link Macros}
 * @see {@link Macro}
 */
class Hotbar extends Application {
  constructor(options) {
    super(options);
    game.macros.apps.push(this);

    /**
     * The currently viewed macro page
     * @type {number}
     */
    this.page = 1;

    /**
     * The currently displayed set of macros
     * @type {Macro[]}
     */
    this.macros = [];

    /**
     * Track collapsed state
     * @type {boolean}
     */
    this._collapsed = false;

    /**
     * Track which hotbar slot is the current hover target, if any
     * @type {number|null}
     */
    this._hover = null;
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "hotbar",
      template: "templates/hud/hotbar.html",
      popOut: false,
      dragDrop: [{ dragSelector: ".macro-icon", dropSelector: "#macro-list" }]
    });
  }

  /* -------------------------------------------- */

  /**
   * Whether the hotbar is locked.
   * @returns {boolean}
   */
  get locked() {
    return game.settings.get("core", "hotbarLock");
  }

  /* -------------------------------------------- */

  /** @override */
  getData(options={}) {
    this.macros = this._getMacrosByPage(this.page);
    return {
      page: this.page,
      macros: this.macros,
      barClass: this._collapsed ? "collapsed" : "",
      locked: this.locked
    };
  }

  /* -------------------------------------------- */

  /**
   * Get the Array of Macro (or null) values that should be displayed on a numbered page of the bar
   * @param {number} page
   * @returns {Macro[]}
   * @private
   */
  _getMacrosByPage(page) {
    const macros = game.user.getHotbarMacros(page);
    for ( let [i, slot] of macros.entries() ) {
      slot.key = i<9 ? i+1 : 0;
      slot.icon = slot.macro ? slot.macro.img : null;
      slot.cssClass = slot.macro ? "active" : "inactive";
      slot.tooltip = slot.macro ? slot.macro.name : null;
    }
    return macros;
  }

  /* -------------------------------------------- */

  /**
   * Collapse the Hotbar, minimizing its display.
   * @returns {Promise}    A promise which resolves once the collapse animation completes
   */
  async collapse() {
    if ( this._collapsed ) return true;
    const toggle = this.element.find("#bar-toggle");
    const icon = toggle.children("i");
    const bar = this.element.find("#action-bar");
    return new Promise(resolve => {
      bar.slideUp(200, () => {
        bar.addClass("collapsed");
        icon.removeClass("fa-caret-down").addClass("fa-caret-up");
        this._collapsed = true;
        resolve(true);
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * Expand the Hotbar, displaying it normally.
   * @returns {Promise}    A promise which resolves once the expand animation completes
   */
  async expand() {
    if ( !this._collapsed ) return true;
    const toggle = this.element.find("#bar-toggle");
    const icon = toggle.children("i");
    const bar = this.element.find("#action-bar");
    return new Promise(resolve => {
      bar.slideDown(200, () => {
        bar.css("display", "");
        bar.removeClass("collapsed");
        icon.removeClass("fa-caret-up").addClass("fa-caret-down");
        this._collapsed = false;
        resolve(true);
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * Change to a specific numbered page from 1 to 5
   * @param {number} page     The page number to change to.
   */
  changePage(page) {
    this.page = Math.clamp(page ?? 1, 1, 5);
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Change the page of the hotbar by cycling up (positive) or down (negative)
   * @param {number} direction    The direction to cycle
   */
  cyclePage(direction) {
    direction = Number.isNumeric(direction) ? Math.sign(direction) : 1;
    if ( direction > 0 ) {
      this.page = this.page < 5 ? this.page+1 : 1;
    } else {
      this.page = this.page > 1 ? this.page-1 : 5;
    }
    this.render();
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);

    // Macro actions
    html.find("#bar-toggle").click(this._onToggleBar.bind(this));
    html.find("#macro-directory").click(ev => ui.macros.renderPopout(true));
    html.find(".macro").click(this._onClickMacro.bind(this));
    html.find(".page-control").click(this._onClickPageControl.bind(this));

    // Activate context menu
    this._contextMenu(html);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _contextMenu(html) {
    ContextMenu.create(this, html, ".macro", this._getEntryContextOptions());
  }

  /* -------------------------------------------- */

  /**
   * Get the Macro entry context options
   * @returns {object[]}  The Macro entry context options
   * @private
   */
  _getEntryContextOptions() {
    return [
      {
        name: "MACRO.Edit",
        icon: '<i class="fas fa-edit"></i>',
        condition: li => {
          const macro = game.macros.get(li.data("macro-id"));
          return macro ? macro.isOwner : false;
        },
        callback: li => {
          const macro = game.macros.get(li.data("macro-id"));
          macro.sheet.render(true);
        }
      },
      {
        name: "MACRO.Remove",
        icon: '<i class="fas fa-times"></i>',
        condition: li => !!li.data("macro-id"),
        callback: li => game.user.assignHotbarMacro(null, Number(li.data("slot")))
      },
      {
        name: "MACRO.Delete",
        icon: '<i class="fas fa-trash"></i>',
        condition: li => {
          const macro = game.macros.get(li.data("macro-id"));
          return macro ? macro.isOwner : false;
        },
        callback: li => {
          const macro = game.macros.get(li.data("macro-id"));
          return Dialog.confirm({
            title: `${game.i18n.localize("MACRO.Delete")} ${macro.name}`,
            content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("MACRO.DeleteWarning")}</p>`,
            yes: macro.delete.bind(macro)
          });
        }
      }
    ];
  }

  /* -------------------------------------------- */

  /**
   * Handle left-click events to
   * @param {MouseEvent} event    The originating click event
   * @protected
   */
  async _onClickMacro(event) {
    event.preventDefault();
    const li = event.currentTarget;

    // Case 1 - create a temporary Macro
    if ( li.classList.contains("inactive") ) {
      const cls = getDocumentClass("Macro");
      const macro = new cls({name: cls.defaultName({type: "chat"}), type: "chat", scope: "global"});
      macro.sheet._hotbarSlot = li.dataset.slot;
      macro.sheet.render(true);
    }

    // Case 2 - trigger a Macro
    else {
      const macro = game.macros.get(li.dataset.macroId);
      return macro.execute();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle pagination controls
   * @param {Event} event   The originating click event
   * @private
   */
  _onClickPageControl(event) {
    const action = event.currentTarget.dataset.action;
    switch ( action ) {
      case "page-up":
        this.cyclePage(1);
        break;

      case "page-down":
        this.cyclePage(-1);
        break;

      case "lock":
        this._toggleHotbarLock();
        break;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _canDragStart(selector) {
    return !this.locked;
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragStart(event) {
    const li = event.currentTarget.closest(".macro");
    const macro = game.macros.get(li.dataset.macroId);
    if ( !macro ) return false;
    const dragData = foundry.utils.mergeObject(macro.toDragData(), {slot: li.dataset.slot});
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /** @override */
  _canDragDrop(selector) {
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDrop(event) {
    event.preventDefault();
    const li = event.target.closest(".macro");
    const slot = Number(li.dataset.slot);
    const data = TextEditor.getDragEventData(event);
    if ( Hooks.call("hotbarDrop", this, data, slot) === false ) return;

    // Forbid overwriting macros if the hotbar is locked.
    const existingMacro = game.macros.get(game.user.hotbar[slot]);
    if ( existingMacro && this.locked ) return ui.notifications.warn("MACRO.CannotOverwrite", { localize: true });

    // Get the dropped document
    const cls = getDocumentClass(data.type);
    const doc = await cls?.fromDropData(data);
    if ( !doc ) return;

    // Get the Macro to add to the bar
    let macro;
    if ( data.type === "Macro" ) macro = game.macros.has(doc.id) ? doc : await cls.create(doc.toObject());
    else if ( data.type === "RollTable" ) macro = await this._createRollTableRollMacro(doc);
    else macro = await this._createDocumentSheetToggle(doc);

    // Assign the macro to the hotbar
    if ( !macro ) return;
    return game.user.assignHotbarMacro(macro, slot, {fromSlot: data.slot});
  }

  /* -------------------------------------------- */

  /**
   * Create a Macro which rolls a RollTable when executed
   * @param {Document} table    The RollTable document
   * @returns {Promise<Macro>}  A created Macro document to add to the bar
   * @private
   */
  async _createRollTableRollMacro(table) {
    const command = `const table = await fromUuid("${table.uuid}");\nawait table.draw();`;
    return Macro.implementation.create({
      name: `${game.i18n.localize("TABLE.Roll")} ${table.name}`,
      type: "script",
      img: table.img,
      command
    });
  }

  /* -------------------------------------------- */

  /**
   * Create a Macro document which can be used to toggle display of a Journal Entry.
   * @param {Document} doc          A Document which should be toggled
   * @returns {Promise<Macro>}      A created Macro document to add to the bar
   * @protected
   */
  async _createDocumentSheetToggle(doc) {
    const name = doc.name || `${game.i18n.localize(doc.constructor.metadata.label)} ${doc.id}`;
    return Macro.implementation.create({
      name: `${game.i18n.localize("Display")} ${name}`,
      type: CONST.MACRO_TYPES.SCRIPT,
      img: "icons/svg/book.svg",
      command: `await Hotbar.toggleDocumentSheet("${doc.uuid}");`
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle click events to toggle display of the macro bar
   * @param {Event} event
   * @private
   */
  _onToggleBar(event) {
    event.preventDefault();
    if ( this._collapsed ) return this.expand();
    else return this.collapse();
  }

  /* -------------------------------------------- */

  /**
   * Toggle the hotbar's lock state.
   * @returns {Promise<Hotbar>}
   * @protected
   */
  async _toggleHotbarLock() {
    await game.settings.set("core", "hotbarLock", !this.locked);
    return this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a document sheet.
   * @param {string} uuid     The Document UUID to display
   * @returns {Promise<void>|Application|*}
   */
  static async toggleDocumentSheet(uuid) {
    const doc = await fromUuid(uuid);
    if ( !doc ) {
      return ui.notifications.warn(game.i18n.format("WARNING.ObjectDoesNotExist", {
        name: game.i18n.localize("Document"),
        identifier: uuid
      }));
    }
    const sheet = doc.sheet;
    return sheet.rendered ? sheet.close() : sheet.render(true);
  }
}

/**
 * An abstract base class for displaying a heads-up-display interface bound to a Placeable Object on the canvas
 * @interface
 * @template {PlaceableObject} ActiveHUDObject
 * @template {CanvasDocument} ActiveHUDDocument
 * @template {PlaceablesLayer} ActiveHUDLayer
 */
class BasePlaceableHUD extends Application {

  /**
   * Reference a PlaceableObject this HUD is currently bound to.
   * @type {ActiveHUDObject}
   */
  object;

  /**
   * Track whether a control icon is hovered or not
   * @type {boolean}
   */
  #hoverControlIcon = false;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["placeable-hud"],
      popOut: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Convenience access to the Document which this HUD modifies.
   * @returns {ActiveHUDDocument}
   */
  get document() {
    return this.object?.document;
  }

  /* -------------------------------------------- */

  /**
   * Convenience access for the canvas layer which this HUD modifies
   * @type {ActiveHUDLayer}
   */
  get layer() {
    return this.object?.layer;
  }

  /* -------------------------------------------- */
  /*  Methods
  /* -------------------------------------------- */

  /**
   * Bind the HUD to a new PlaceableObject and display it
   * @param {PlaceableObject} object    A PlaceableObject instance to which the HUD should be bound
   */
  bind(object) {
    const states = this.constructor.RENDER_STATES;
    if ( [states.CLOSING, states.RENDERING].includes(this._state) ) return;
    if ( this.object ) this.clear();

    // Record the new object
    if ( !(object instanceof PlaceableObject) || (object.scene !== canvas.scene) ) {
      throw new Error("You may only bind a HUD instance to a PlaceableObject in the currently viewed Scene.");
    }
    this.object = object;

    // Render the HUD
    this.render(true);
    this.element.hide().fadeIn(200);
  }

  /* -------------------------------------------- */

  /**
   * Clear the HUD by fading out it's active HTML and recording the new display state
   */
  clear() {
    let states = this.constructor.RENDER_STATES;
    if ( this._state <= states.NONE ) return;
    this._state = states.CLOSING;

    // Unbind
    this.object = null;
    this.element.hide();
    this._element = null;
    this._state = states.NONE;
  }

  /* -------------------------------------------- */

  /** @override */
  async _render(...args) {
    await super._render(...args);
    this.setPosition();
  }

  /* -------------------------------------------- */

  /** @override */
  getData(options = {}) {
    const data = this.object.document.toObject();
    return foundry.utils.mergeObject(data, {
      id: this.id,
      classes: this.options.classes.join(" "),
      appId: this.appId,
      isGM: game.user.isGM,
      isGamePaused: game.paused,
      icons: CONFIG.controlIcons
    });
  }

  /* -------------------------------------------- */

  /** @override */
  setPosition({left, top, width, height, scale} = {}) {
    const position = {
      width: width || this.object.width,
      height: height || this.object.height,
      left: left ?? this.object.x,
      top: top ?? this.object.y
    };
    this.element.css(position);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    // Attribute Bars
    html.find(".attribute input")
      .click(this._onAttributeClick)
      .keydown(this._onAttributeKeydown.bind(this))
      .focusout(this._onAttributeUpdate.bind(this));

    // Control icons hover detection
    html.find(".control-icon")
      .mouseleave(() => this.#hoverControlIcon = false)
      .mouseenter(() => this.#hoverControlIcon = true)
      .click(this._onClickControl.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse clicks to control a HUD control button
   * @param {PointerEvent} event    The originating click event
   * @protected
   */
  _onClickControl(event) {
    const button = event.currentTarget;
    switch ( button.dataset.action ) {
      case "visibility":
        return this._onToggleVisibility(event);
      case "locked":
        return this._onToggleLocked(event);
      case "sort-up":
        return this._onSort(event, true);
      case "sort-down":
        return this._onSort(event, false);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle initial click to focus an attribute update field
   * @param {MouseEvent} event        The mouse click event
   * @protected
   */
  _onAttributeClick(event) {
    event.currentTarget.select();
  }

  /* -------------------------------------------- */

  /**
   * Force field handling on an Enter keypress even if the value of the field did not change.
   * This is important to suppose use cases with negative number values.
   * @param {KeyboardEvent} event     The originating keydown event
   * @protected
   */
  _onAttributeKeydown(event) {
    if ( (event.code === "Enter") || (event.code === "NumpadEnter") ) event.currentTarget.blur();
  }

  /* -------------------------------------------- */

  /**
   * Handle attribute updates
   * @param {FocusEvent} event        The originating focusout event
   */
  _onAttributeUpdate(event) {
    event.preventDefault();
    if ( !this.object ) return;
    const input = event.currentTarget;
    this._updateAttribute(input.name, event.currentTarget.value.trim());
    if ( !this.#hoverControlIcon ) this.clear();
  }

  /* -------------------------------------------- */

  /**
   * Handle attribute bar update
   * @param {string} name           The name of the attribute
   * @param {string} input          The raw string input value for the update
   * @returns {Promise<void>}
   * @protected
   */
  async _updateAttribute(name, input) {
    const current = foundry.utils.getProperty(this.object.document, name);
    const {value} = this._parseAttributeInput(name, current, input);
    await this.object.document.update({[name]: value});
  }

  /* -------------------------------------------- */

  /**
   * Parse an attribute bar input string into a new value for the attribute field.
   * @param {string} name           The name of the attribute
   * @param {object|number} attr    The current value of the attribute
   * @param {string} input          The raw string input value
   * @returns {{value: number, [delta]: number, isDelta: boolean, isBar: boolean}} The parsed input value
   * @protected
   */
  _parseAttributeInput(name, attr, input) {
    const isBar = (typeof attr === "object") && ("max" in attr);
    const isEqual = input.startsWith("=");
    const isDelta = input.startsWith("+") || input.startsWith("-");
    const current = isBar ? attr.value : attr;
    let v;

    // Explicit equality
    if ( isEqual ) input = input.slice(1);

    // Percentage change
    if ( input.endsWith("%") ) {
      const p = Number(input.slice(0, -1)) / 100;
      if ( isBar ) v = attr.max * p;
      else v = Math.abs(current) * p;
    }

    // Additive delta
    else v = Number(input);

    // Return parsed input
    const value = isDelta ? current + v : v;
    const delta = isDelta ? v : undefined;
    return {value, delta, isDelta, isBar};
  }

  /* -------------------------------------------- */

  /**
   * Toggle the visible state of all controlled objects in the Layer
   * @param {PointerEvent} event    The originating click event
   * @private
   */
  async _onToggleVisibility(event) {
    event.preventDefault();

    // Toggle the visible state
    const isHidden = this.object.document.hidden;
    const updates = this.layer.controlled.map(o => {
      return {_id: o.id, hidden: !isHidden};
    });

    // Update all objects
    return canvas.scene.updateEmbeddedDocuments(this.object.document.documentName, updates);
  }

  /* -------------------------------------------- */

  /**
   * Toggle locked state of all controlled objects in the Layer
   * @param {PointerEvent} event    The originating click event
   * @private
   */
  async _onToggleLocked(event) {
    event.preventDefault();

    // Toggle the visible state
    const isLocked = this.object.document.locked;
    const updates = this.layer.controlled.map(o => {
      return {_id: o.id, locked: !isLocked};
    });

    // Update all objects
    event.currentTarget.classList.toggle("active", !isLocked);
    return canvas.scene.updateEmbeddedDocuments(this.object.document.documentName, updates);
  }

  /* -------------------------------------------- */

  /**
   * Handle sorting the z-order of the object
   * @param {PointerEvent} event    The originating mouse click event
   * @param {boolean} up            Move the object upwards in the vertical stack?
   *                                If false, the object is moved downwards.
   * @returns {Promise<void>}
   * @protected
   */
  async _onSort(event, up) {
    event.preventDefault();
    this.layer._sendToBackOrBringToFront(up);
  }
}

/**
 * The main menu application which is toggled via the ESC key.
 * @extends {Application}
 */
class MainMenu extends Application {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "menu",
      template: "templates/hud/menu.html",
      popOut: false
    });
  }

  /* ----------------------------------------- */

  /**
   * The structure of menu items
   * @returns {Record<string, {label: string, icon: string, enabled: boolean, onClick: Function}>}
   */
  get items() {
    return {
      reload: {
        label: "MENU.Reload",
        icon: '<i class="fas fa-redo"></i>',
        enabled: true,
        onClick: () => window.location.reload()
      },
      logout: {
        label: "MENU.Logout",
        icon: '<i class="fas fa-user"></i>',
        enabled: true,
        onClick: () => game.logOut()
      },
      players: {
        label: "MENU.Players",
        icon: '<i class="fas fa-users"></i>',
        enabled: game.user.isGM && !game.data.demoMode,
        onClick: () => window.location.href = "./players"
      },
      world: {
        label: "GAME.ReturnSetup",
        icon: '<i class="fas fa-globe"></i>',
        enabled: game.user.hasRole("GAMEMASTER") && !game.data.demoMode,
        onClick: () => {
          this.close();
          game.shutDown();
        }
      }
    };
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    return {
      items: this.items
    };
  }

  /* ----------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    for ( let [k, v] of Object.entries(this.items) ) {
      html.find(`.menu-${k}`).click(v.onClick);
    }
  }

  /* ----------------------------------------- */

  /**
   * Toggle display of the menu (or render it in the first place)
   */
  toggle() {
    let menu = this.element;
    if ( !this.rendered ) this.render(true);
    else menu.slideToggle(150);
  }
}

/**
 * The UI element which displays the Scene documents which are currently enabled for quick navigation.
 */
class SceneNavigation extends Application {
  constructor(options) {
    super(options);
    game.scenes.apps.push(this);

    /**
     * Navigation collapsed state
     * @type {boolean}
     */
    this._collapsed = false;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "navigation",
      template: "templates/hud/navigation.html",
      popOut: false,
      dragDrop: [{dragSelector: ".scene"}]
    });
  }

  /* -------------------------------------------- */

  /**
   * Return an Array of Scenes which are displayed in the Navigation bar
   * @returns {Scene[]}
   */
  get scenes() {
    const scenes = game.scenes.filter(s => {
      return (s.navigation && s.visible) || s.active || s.isView;
    });
    scenes.sort((a, b) => a.navOrder - b.navOrder);
    return scenes;
  }

  /* -------------------------------------------- */

  /*  Application Rendering
  /* -------------------------------------------- */

  /** @inheritdoc */
  render(force, context = {}) {
    let {renderContext, renderData} = context;
    if ( renderContext ) {
      const events = ["createScene", "updateScene", "deleteScene"];
      if ( !events.includes(renderContext) ) return this;
      const updateKeys = ["name", "ownership", "active", "navigation", "navName", "navOrder"];
      if ( (renderContext === "updateScene") && !renderData.some(d => updateKeys.some(k => k in d)) ) return this;
    }
    return super.render(force, context);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _render(force, options) {
    await super._render(force, options);
    const loading = document.getElementById("loading");
    const nav = this.element[0];
    loading.style.top = `${nav.offsetTop + nav.offsetHeight}px`;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    const scenes = this.scenes.map(scene => {
      return {
        id: scene.id,
        active: scene.active,
        name: TextEditor.truncateText(scene.navName || scene.name, {maxLength: 32}),
        tooltip: scene.navName && game.user.isGM ? scene.name : null,
        users: game.users.reduce((arr, u) => {
          if ( u.active && ( u.viewedScene === scene.id) ) arr.push({letter: u.name[0], color: u.color.css});
          return arr;
        }, []),
        visible: game.user.isGM || scene.isOwner || scene.active,
        css: [
          scene.isView ? "view" : null,
          scene.active ? "active" : null,
          scene.ownership.default === 0 ? "gm" : null
        ].filterJoin(" ")
      };
    });
    return {collapsed: this._collapsed, scenes: scenes};
  }

  /* -------------------------------------------- */

  /**
   * A hook event that fires when the SceneNavigation menu is expanded or collapsed.
   * @function collapseSceneNavigation
   * @memberof hookEvents
   * @param {SceneNavigation} sceneNavigation The SceneNavigation application
   * @param {boolean} collapsed               Whether the SceneNavigation is now collapsed or not
   */

  /* -------------------------------------------- */

  /**
   * Expand the SceneNavigation menu, sliding it down if it is currently collapsed
   */
  expand() {
    if ( !this._collapsed ) return true;
    const nav = this.element;
    const icon = nav.find("#nav-toggle i.fas");
    const ul = nav.children("#scene-list");
    return new Promise(resolve => {
      ul.slideDown(200, () => {
        nav.removeClass("collapsed");
        icon.removeClass("fa-caret-down").addClass("fa-caret-up");
        this._collapsed = false;
        Hooks.callAll("collapseSceneNavigation", this, this._collapsed);
        return resolve(true);
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * Collapse the SceneNavigation menu, sliding it up if it is currently expanded
   * @returns {Promise<boolean>}
   */
  async collapse() {
    if ( this._collapsed ) return true;
    const nav = this.element;
    const icon = nav.find("#nav-toggle i.fas");
    const ul = nav.children("#scene-list");
    return new Promise(resolve => {
      ul.slideUp(200, () => {
        nav.addClass("collapsed");
        icon.removeClass("fa-caret-up").addClass("fa-caret-down");
        this._collapsed = true;
        Hooks.callAll("collapseSceneNavigation", this, this._collapsed);
        return resolve(true);
      });
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers
  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);

    // Click event listener
    const scenes = html.find(".scene");
    scenes.click(this._onClickScene.bind(this));
    html.find("#nav-toggle").click(this._onToggleNav.bind(this));

    // Activate Context Menu
    const contextOptions = this._getContextMenuOptions();
    Hooks.call("getSceneNavigationContext", html, contextOptions);
    if ( contextOptions ) new ContextMenu(html, ".scene", contextOptions);
  }

  /* -------------------------------------------- */

  /**
   * Get the set of ContextMenu options which should be applied for Scenes in the menu
   * @returns {object[]}   The Array of context options passed to the ContextMenu instance
   * @private
   */
  _getContextMenuOptions() {
    return [
      {
        name: "SCENES.Activate",
        icon: '<i class="fas fa-bullseye"></i>',
        condition: li => game.user.isGM && !game.scenes.get(li.data("sceneId")).active,
        callback: li => {
          let scene = game.scenes.get(li.data("sceneId"));
          scene.activate();
        }
      },
      {
        name: "SCENES.Configure",
        icon: '<i class="fas fa-cogs"></i>',
        condition: game.user.isGM,
        callback: li => {
          let scene = game.scenes.get(li.data("sceneId"));
          scene.sheet.render(true);
        }
      },
      {
        name: "SCENES.Notes",
        icon: '<i class="fas fa-scroll"></i>',
        condition: li => {
          if ( !game.user.isGM ) return false;
          const scene = game.scenes.get(li.data("sceneId"));
          return !!scene.journal;
        },
        callback: li => {
          const scene = game.scenes.get(li.data("sceneId"));
          const entry = scene.journal;
          if ( entry ) {
            const sheet = entry.sheet;
            const options = {};
            if ( scene.journalEntryPage ) options.pageId = scene.journalEntryPage;
            sheet.render(true, options);
          }
        }
      },
      {
        name: "SCENES.Preload",
        icon: '<i class="fas fa-download"></i>',
        condition: game.user.isGM,
        callback: li => {
          let sceneId = li.attr("data-scene-id");
          game.scenes.preload(sceneId, true);
        }
      },
      {
        name: "SCENES.ToggleNav",
        icon: '<i class="fas fa-compass"></i>',
        condition: li => {
          const scene = game.scenes.get(li.data("sceneId"));
          return game.user.isGM && (!scene.active);
        },
        callback: li => {
          const scene = game.scenes.get(li.data("sceneId"));
          scene.update({navigation: !scene.navigation});
        }
      }
    ];
  }

  /* -------------------------------------------- */

  /**
   * Handle left-click events on the scenes in the navigation menu
   * @param {PointerEvent} event
   * @private
   */
  _onClickScene(event) {
    event.preventDefault();
    let sceneId = event.currentTarget.dataset.sceneId;
    game.scenes.get(sceneId).view();
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragStart(event) {
    const sceneId = event.currentTarget.dataset.sceneId;
    const scene = game.scenes.get(sceneId);
    event.dataTransfer.setData("text/plain", JSON.stringify(scene.toDragData()));
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDrop(event) {
    const data = TextEditor.getDragEventData(event);
    if ( data.type !== "Scene" ) return;

    // Identify the document, the drop target, and the set of siblings
    const scene = await Scene.implementation.fromDropData(data);
    const dropTarget = event.target.closest(".scene") || null;
    const sibling = dropTarget ? game.scenes.get(dropTarget.dataset.sceneId) : null;
    if ( sibling && (sibling.id === scene.id) ) return;
    const siblings = this.scenes.filter(s => s.id !== scene.id);

    // Update the navigation sorting for each Scene
    return scene.sortRelative({
      target: sibling,
      siblings: siblings,
      sortKey: "navOrder"
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle navigation menu toggle click events
   * @param {Event} event
   * @private
   */
  _onToggleNav(event) {
    event.preventDefault();
    if ( this._collapsed ) return this.expand();
    else return this.collapse();
  }

  /* -------------------------------------------- */

  /**
   * Display progress of some major operation like loading Scene textures.
   * @param {object} options    Options for how the progress bar is displayed
   * @param {string} options.label  A text label to display
   * @param {number} options.pct    A percentage of progress between 0 and 100
   */
  static displayProgressBar({label, pct} = {}) {
    const loader = document.getElementById("loading");
    pct = Math.clamp(pct, 0, 100);
    loader.querySelector("#context").textContent = label;
    loader.querySelector("#loading-bar").style.width = `${pct}%`;
    loader.querySelector("#progress").textContent = `${pct}%`;
    loader.style.display = "block";
    if ( (pct === 100) && !loader.hidden ) $(loader).fadeOut(2000);
  }
}

/**
 * Pause notification in the HUD
 * @extends {Application}
 */
class Pause extends Application {
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.id = "pause";
    options.template = "templates/hud/pause.html";
    options.popOut = false;
    return options;
  }

  /** @override */
  getData(options={}) {
    return { paused: game.paused };
  }
}


/**
 * The UI element which displays the list of Users who are currently playing within the active World.
 * @extends {Application}
 */
class PlayerList extends Application {
  constructor(options) {
    super(options);
    game.users.apps.push(this);

    /**
     * An internal toggle for whether to show offline players or hide them
     * @type {boolean}
     * @private
     */
    this._showOffline = false;
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "players",
      template: "templates/user/players.html",
      popOut: false
    });
  }

  /* -------------------------------------------- */
  /*  Application Rendering                       */
  /* -------------------------------------------- */

  /**
   * Whether the players list is in a configuration where it is hidden.
   * @returns {boolean}
   */
  get isHidden() {
    if ( game.webrtc.mode === AVSettings.AV_MODES.DISABLED ) return false;
    const { client, verticalDock } = game.webrtc.settings;
    return verticalDock && client.hidePlayerList && !client.hideDock && !ui.webrtc.hidden;
  }

  /* -------------------------------------------- */

  /** @override */
  render(force, context={}) {
    this._positionInDOM();
    const { renderContext, renderData } = context;
    if ( renderContext ) {
      const events = ["createUser", "updateUser", "deleteUser"];
      if ( !events.includes(renderContext) ) return this;
      if ( renderContext === "updateUser" ) {
        const updateKeys = ["name", "pronouns", "ownership", "ownership.default", "active", "navigation"];
        if ( !renderData.some(d => updateKeys.some(k => k in d)) ) return this;
      }
    }
    return super.render(force, context);
  }

  /* -------------------------------------------- */

  /** @override */
  getData(options={}) {

    // Process user data by adding extra characteristics
    const users = game.users.filter(u => this._showOffline || u.active).map(user => {
      const u = user.toObject(false);
      u.active = user.active;
      u.isGM = user.isGM;
      u.isSelf = user.isSelf;
      u.charname = user.character?.name.split(" ")[0] || "";
      u.color = u.active ? u.color.css : "#333333";
      u.border = u.active ? user.border.css : "#000000";
      u.displayName = this._getDisplayName(u);
      return u;
    }).sort((a, b) => {
      if ( (b.role >= CONST.USER_ROLES.ASSISTANT) && (b.role > a.role) ) return 1;
      return a.name.localeCompare(b.name, game.i18n.lang);
    });

    // Return the data for rendering
    return {
      users,
      hide: this.isHidden,
      showOffline: this._showOffline
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare a displayed name string for the User which includes their name, pronouns, character, or GM tag.
   * @returns {string}
   * @protected
   */
  _getDisplayName(user) {
    const displayNamePart = [user.name];
    if ( user.pronouns ) displayNamePart.push(`(${user.pronouns})`);
    if ( user.isGM ) displayNamePart.push(`[${game.i18n.localize("USER.GM")}]`);
    else if ( user.charname ) displayNamePart.push(`[${user.charname}]`);
    return displayNamePart.join(" ");
  }

  /* -------------------------------------------- */

  /**
   * Position this Application in the main DOM appropriately.
   * @protected
   */
  _positionInDOM() {
    document.body.classList.toggle("players-hidden", this.isHidden);
    if ( (game.webrtc.mode === AVSettings.AV_MODES.DISABLED) || this.isHidden || !this.element.length ) return;
    const element = this.element[0];
    const cameraViews = ui.webrtc.element[0];
    const uiTop = document.getElementById("ui-top");
    const uiLeft = document.getElementById("ui-left");
    const { client, verticalDock } = game.webrtc.settings;
    const inDock = verticalDock && !client.hideDock && !ui.webrtc.hidden;

    if ( inDock && !cameraViews?.contains(element) ) {
      cameraViews.appendChild(element);
      uiTop.classList.remove("offset");
    } else if ( !inDock && !uiLeft.contains(element) ) {
      uiLeft.appendChild(element);
      uiTop.classList.add("offset");
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {

    // Toggle online/offline
    html.find("h3").click(this._onToggleOfflinePlayers.bind(this));

    // Context menu
    const contextOptions = this._getUserContextOptions();
    Hooks.call("getUserContextOptions", html, contextOptions);
    new ContextMenu(html, ".player", contextOptions);
  }

  /* -------------------------------------------- */

  /**
   * Return the default context options available for the Players application
   * @returns {object[]}
   * @private
   */
  _getUserContextOptions() {
    return [
      {
        name: game.i18n.localize("PLAYERS.ConfigTitle"),
        icon: '<i class="fas fa-male"></i>',
        condition: li => game.user.isGM || (li[0].dataset.userId === game.user.id),
        callback: li => {
          const user = game.users.get(li[0].dataset.userId);
          user?.sheet.render(true);
        }
      },
      {
        name: game.i18n.localize("PLAYERS.ViewAvatar"),
        icon: '<i class="fas fa-image"></i>',
        condition: li => {
          const user = game.users.get(li[0].dataset.userId);
          return user.avatar !== CONST.DEFAULT_TOKEN;
        },
        callback: li => {
          let user = game.users.get(li.data("user-id"));
          new ImagePopout(user.avatar, {
            title: user.name,
            uuid: user.uuid
          }).render(true);
        }
      },
      {
        name: game.i18n.localize("PLAYERS.PullToScene"),
        icon: '<i class="fas fa-directions"></i>',
        condition: li => game.user.isGM && (li[0].dataset.userId !== game.user.id),
        callback: li => game.socket.emit("pullToScene", canvas.scene.id, li.data("user-id"))
      },
      {
        name: game.i18n.localize("PLAYERS.Kick"),
        icon: '<i class="fas fa-door-open"></i>',
        condition: li => {
          const user = game.users.get(li[0].dataset.userId);
          return game.user.isGM && user.active && !user.isSelf;
        },
        callback: li => {
          const user = game.users.get(li[0].dataset.userId);
          return this.#kickUser(user);
        }
      },
      {
        name: game.i18n.localize("PLAYERS.Ban"),
        icon: '<i class="fas fa-ban"></i>',
        condition: li => {
          const user = game.users.get(li[0].dataset.userId);
          return game.user.isGM && !user.isSelf && (user.role !== CONST.USER_ROLES.NONE);
        },
        callback: li => {
          const user = game.users.get(li[0].dataset.userId);
          return this.#banUser(user);
        }
      },
      {
        name: game.i18n.localize("PLAYERS.UnBan"),
        icon: '<i class="fas fa-ban"></i>',
        condition: li => {
          const user = game.users.get(li[0].dataset.userId);
          return game.user.isGM && !user.isSelf && (user.role === CONST.USER_ROLES.NONE);
        },
        callback: li => {
          const user = game.users.get(li[0].dataset.userId);
          return this.#unbanUser(user);
        }
      },
      {
        name: game.i18n.localize("WEBRTC.TooltipShowUser"),
        icon: '<i class="fas fa-eye"></i>',
        condition: li => {
          const userId = li.data("userId");
          return game.webrtc.settings.client.users[userId]?.blocked;
        },
        callback: async li => {
          const userId = li.data("userId");
          await game.webrtc.settings.set("client", `users.${userId}.blocked`, false);
          ui.webrtc.render();
        }
      }
    ];
  }

  /* -------------------------------------------- */

  /**
   * Toggle display of the Players hud setting for whether to display offline players
   * @param {Event} event   The originating click event
   * @private
   */
  _onToggleOfflinePlayers(event) {
    event.preventDefault();
    this._showOffline = !this._showOffline;
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Temporarily remove a User from the World by banning and then un-banning them.
   * @param {User} user     The User to kick
   * @returns {Promise<void>}
   */
  async #kickUser(user) {
    const role = user.role;
    await user.update({role: CONST.USER_ROLES.NONE});
    await user.update({role}, {diff: false});
    ui.notifications.info(`${user.name} has been <strong>kicked</strong> from the World.`);
  }

  /* -------------------------------------------- */

  /**
   * Ban a User by changing their role to "NONE".
   * @param {User} user     The User to ban
   * @returns {Promise<void>}
   */
  async #banUser(user) {
    if ( user.role === CONST.USER_ROLES.NONE ) return;
    await user.update({role: CONST.USER_ROLES.NONE});
    ui.notifications.info(`${user.name} has been <strong>banned</strong> from the World.`);
  }

  /* -------------------------------------------- */

  /**
   * Unban a User by changing their role to "PLAYER".
   * @param {User} user     The User to unban
   * @returns {Promise<void>}
   */
  async #unbanUser(user) {
    if ( user.role !== CONST.USER_ROLES.NONE ) return;
    await user.update({role: CONST.USER_ROLES.PLAYER});
    ui.notifications.info(`${user.name} has been <strong>unbanned</strong> from the World.`);
  }
}

/**
 * Audio/Video Conferencing Configuration Sheet
 * @extends {FormApplication}
 *
 * @param {AVMaster} object                   The {@link AVMaster} instance being configured.
 * @param {FormApplicationOptions} [options]  Application configuration options.
 */
class AVConfig extends FormApplication {
  constructor(object, options) {
    super(object || game.webrtc, options);
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      title: game.i18n.localize("WEBRTC.Title"),
      id: "av-config",
      template: "templates/sidebar/apps/av-config.html",
      popOut: true,
      width: 480,
      height: "auto",
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "general"}]
    });
  }

  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    const settings = this.object.settings;
    const videoSources = await this.object.client.getVideoSources();
    const audioSources = await this.object.client.getAudioSources();
    const audioSinks = await this.object.client.getAudioSinks();

    // If the currently chosen device is unavailable, display a separate option for 'unavailable device (use default)'
    const { videoSrc, audioSrc, audioSink } = settings.client;
    const videoSrcUnavailable = this._isSourceUnavailable(videoSources, videoSrc);
    const audioSrcUnavailable = this._isSourceUnavailable(audioSources, audioSrc);
    const audioSinkUnavailable = this._isSourceUnavailable(audioSinks, audioSink);
    const isSSL = window.location.protocol === "https:";

    // Audio/Video modes
    const modes = {
      [AVSettings.AV_MODES.DISABLED]: "WEBRTC.ModeDisabled",
      [AVSettings.AV_MODES.AUDIO]: "WEBRTC.ModeAudioOnly",
      [AVSettings.AV_MODES.VIDEO]: "WEBRTC.ModeVideoOnly",
      [AVSettings.AV_MODES.AUDIO_VIDEO]: "WEBRTC.ModeAudioVideo"
    };

    // Voice Broadcast modes
    const voiceModes = Object.values(AVSettings.VOICE_MODES).reduce((obj, m) => {
      obj[m] = game.i18n.localize(`WEBRTC.VoiceMode${m.titleCase()}`);
      return obj;
    }, {});

    // Nameplate settings.
    const nameplates = {
      [AVSettings.NAMEPLATE_MODES.OFF]: "WEBRTC.NameplatesOff",
      [AVSettings.NAMEPLATE_MODES.PLAYER_ONLY]: "WEBRTC.NameplatesPlayer",
      [AVSettings.NAMEPLATE_MODES.CHAR_ONLY]: "WEBRTC.NameplatesCharacter",
      [AVSettings.NAMEPLATE_MODES.BOTH]: "WEBRTC.NameplatesBoth"
    };

    const dockPositions = Object.fromEntries(Object.values(AVSettings.DOCK_POSITIONS).map(p => {
      return [p, game.i18n.localize(`WEBRTC.DockPosition${p.titleCase()}`)];
    }));

    // Return data to the template
    return {
      user: game.user,
      modes,
      voiceModes,
      serverTypes: {FVTT: "WEBRTC.FVTTSignalingServer", custom: "WEBRTC.CustomSignalingServer"},
      turnTypes: {server: "WEBRTC.TURNServerProvisioned", custom: "WEBRTC.CustomTURNServer"},
      settings,
      canSelectMode: game.user.isGM && isSSL,
      noSSL: !isSSL,
      videoSources,
      audioSources,
      audioSinks: foundry.utils.isEmpty(audioSinks) ? false : audioSinks,
      videoSrcUnavailable,
      audioSrcUnavailable,
      audioSinkUnavailable,
      audioDisabled: audioSrc === "disabled",
      videoDisabled: videoSrc === "disabled",
      nameplates,
      nameplateSetting: settings.client.nameplates ?? AVSettings.NAMEPLATE_MODES.BOTH,
      dockPositions,
      audioSourceOptions: this.#getDevices(audioSources, audioSrcUnavailable, "WEBRTC.DisableAudioSource"),
      audioSinkOptions: this.#getDevices(audioSinks, audioSinkUnavailable),
      videoSourceOptions: this.#getDevices(videoSources, videoSrcUnavailable, "WEBRTC.DisableVideoSource")
    };
  }

  /* -------------------------------------------- */

  /**
   * Get an array of available devices which can be chosen.
   * @param {Record<string, string>} devices
   * @param {string} unavailableDevice
   * @param {string} disabledLabel
   * @returns {FormSelectOption[]}
   */
  #getDevices(devices, unavailableDevice, disabledLabel) {
    const options = [];
    let hasDefault = false;
    for ( const [k, v] of Object.entries(devices) ) {
      if ( k === "default" ) hasDefault = true;
      options.push({value: k, label: v});
    }
    if ( !hasDefault ) {
      options.unshift({value: "default", label: game.i18n.localize("WEBRTC.DefaultSource")});
    }
    if ( disabledLabel ) {
      options.unshift({value: "disabled", label: game.i18n.localize(disabledLabel)});
    }
    if ( unavailableDevice ) {
      options.push({value: unavailableDevice, label: game.i18n.localize("WEBRTC.UnavailableDevice")});
    }
    return options;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);

    // Options below are GM only
    if ( !game.user.isGM ) return;
    html.find('select[name="world.turn.type"]').change(this._onTurnTypeChanged.bind(this));

    // Activate or de-activate the custom server and turn configuration sections based on current settings
    const settings = this.object.settings;
    this._setConfigSectionEnabled(".webrtc-custom-turn-config", settings.world.turn.type === "custom");
  }

  /* -------------------------------------------- */

  /**
   * Set a section's input to enabled or disabled
   * @param {string} selector    Selector for the section to enable or disable
   * @param {boolean} enabled    Whether to enable or disable this section
   * @private
   */
  _setConfigSectionEnabled(selector, enabled = true) {
    let section = this.element.find(selector);
    if (section) {
      section.css("opacity", enabled ? 1.0 : 0.5);
      section.find("input").prop("disabled", !enabled);
    }
  }

  /* -------------------------------------------- */

  /**
   * Determine whether a given video or audio source, or audio sink has become
   * unavailable since the last time it was set.
   * @param {object} sources The available devices
   * @param {string} source  The selected device
   * @private
   */
  _isSourceUnavailable(sources, source) {
    const specialValues = ["default", "disabled"];
    return source && (!specialValues.includes(source)) && !Object.keys(sources).includes(source);
  }

  /* -------------------------------------------- */

  /**
   * Callback when the turn server type changes
   * Will enable or disable the turn section based on whether the user selected a custom turn or not
   * @param {Event} event   The event that triggered the turn server type change
   * @private
   */
  _onTurnTypeChanged(event) {
    event.preventDefault();
    const choice = event.currentTarget.value;
    this._setConfigSectionEnabled(".webrtc-custom-turn-config", choice === "custom")
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    const settings = game.webrtc.settings;
    settings.client.videoSrc = settings.client.videoSrc || null;
    settings.client.audioSrc = settings.client.audioSrc || null;

    const update = foundry.utils.expandObject(formData);

    // Update world settings
    if ( game.user.isGM ) {
      if ( settings.world.mode !== update.world.mode ) SettingsConfig.reloadConfirm({world: true});
      const world = foundry.utils.mergeObject(settings.world, update.world);
      await game.settings.set("core", "rtcWorldSettings", world);
    }

    // Update client settings
    const client = foundry.utils.mergeObject(settings.client, update.client);
    await game.settings.set("core", "rtcClientSettings", client);
  }
}

/**
 * Abstraction of the Application interface to be used with the Draggable class as a substitute for the app
 * This class will represent one popout feed window and handle its positioning and draggability
 * @param {CameraViews} view      The CameraViews application that this popout belongs to
 * @param {string} userId         ID of the user this popout belongs to
 * @param {jQuery} element        The div element of this specific popout window
 */
class CameraPopoutAppWrapper {
  constructor(view, userId, element) {
    this.view = view;
    this.element = element;
    this.userId = userId;

    // "Fake" some application attributes
    this.popOut = true;
    this.options = {};

    // Get the saved position
    let setting = game.webrtc.settings.getUser(userId);
    this.setPosition(setting);
    new Draggable(this, element.find(".camera-view"), element.find(".video-container")[0], true);
  }

  /* -------------------------------------------- */

  /**
   * Get the current position of this popout window
   */
  get position() {
    return foundry.utils.mergeObject(this.element.position(), {
      width: this.element.outerWidth(),
      height: this.element.outerHeight(),
      scale: 1
    });
  }

  /* -------------------------------------------- */

  /** @override */
  setPosition(options={}) {
    const position = Application.prototype.setPosition.call(this, options);
    // Let the HTML renderer figure out the height based on width.
    this.element[0].style.height = "";
    if ( !foundry.utils.isEmpty(position) ) {
      const current = game.webrtc.settings.client.users[this.userId] || {};
      const update = foundry.utils.mergeObject(current, position);
      game.webrtc.settings.set("client", `users.${this.userId}`, update);
    }
    return position;
  }

  /* -------------------------------------------- */

  _onResize(event) {}

  /* -------------------------------------------- */

  /** @override */
  bringToTop() {
    let parent = this.element.parent();
    let children = parent.children();
    let lastElement = children[children.length - 1];
    if (lastElement !== this.element[0]) {
      game.webrtc.settings.set("client", `users.${this.userId}.z`, ++this.view.maxZ);
      parent.append(this.element);
    }
  }
}

/**
 * The Camera UI View that displays all the camera feeds as individual video elements.
 * @type {Application}
 *
 * @param {WebRTC} webrtc                 The WebRTC Implementation to display
 * @param {ApplicationOptions} [options]  Application configuration options.
 */
class CameraViews extends Application {
  constructor(options={}) {
    if ( !("width" in options) ) options.width = game.webrtc?.settings.client.dockWidth || 240;
    super(options);
    if ( game.webrtc?.settings.client.dockPosition === AVSettings.DOCK_POSITIONS.RIGHT ) {
      this.options.resizable.rtl = true;
    }
  }

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "camera-views",
      template: "templates/hud/camera-views.html",
      popOut: false,
      width: 240,
      resizable: {selector: ".camera-view-width-control", resizeY: false}
    });
  }

  /* -------------------------------------------- */

  /**
   * A reference to the master AV orchestrator instance
   * @type {AVMaster}
   */
  get webrtc() {
    return game.webrtc;
  }

  /* -------------------------------------------- */

  /**
   * If all camera views are popped out, hide the dock.
   * @type {boolean}
   */
  get hidden() {
    return this.webrtc.client.getConnectedUsers().reduce((hidden, u) => {
      const settings = this.webrtc.settings.users[u];
      return hidden && (settings.blocked || settings.popout);
    }, true);
  }

  /* -------------------------------------------- */
  /* Public API                                   */
  /* -------------------------------------------- */

  /**
   * Obtain a reference to the div.camera-view which is used to portray a given Foundry User.
   * @param {string} userId     The ID of the User document
   * @return {HTMLElement|null}
   */
  getUserCameraView(userId) {
    return this.element.find(`.camera-view[data-user=${userId}]`)[0] || null;
  }

  /* -------------------------------------------- */

  /**
   * Obtain a reference to the video.user-camera which displays the video channel for a requested Foundry User.
   * If the user is not broadcasting video this will return null.
   * @param {string} userId     The ID of the User document
   * @return {HTMLVideoElement|null}
   */
  getUserVideoElement(userId) {
    return this.element.find(`.camera-view[data-user=${userId}] video.user-camera`)[0] || null;
  }

  /* -------------------------------------------- */

  /**
   * Sets whether a user is currently speaking or not
   *
   * @param {string} userId     The ID of the user
   * @param {boolean} speaking  Whether the user is speaking
   */
  setUserIsSpeaking(userId, speaking) {
    const view = this.getUserCameraView(userId);
    if ( view ) view.classList.toggle("speaking", speaking);
  }

  /* -------------------------------------------- */
  /*  Application Rendering                       */
  /* -------------------------------------------- */

  /**
   * Extend the render logic to first check whether a render is necessary based on the context
   * If a specific context was provided, make sure an update to the navigation is necessary before rendering
   */
  render(force, context={}) {
    const { renderContext, renderData } = context;
    if ( this.webrtc.mode === AVSettings.AV_MODES.DISABLED ) return this;
    if ( renderContext ) {
      if ( renderContext !== "updateUser" ) return this;
      const updateKeys = ["name", "permissions", "role", "active", "color", "sort", "character", "avatar"];
      if ( !updateKeys.some(k => renderData.hasOwnProperty(k)) ) return this;
    }
    return super.render(force, context);
  }

  /* -------------------------------------------- */

  /** @override */
  async _render(force = false, options = {}) {
    await super._render(force, options);
    this.webrtc.onRender();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  setPosition({left, top, width, scale} = {}) {
    const position = super.setPosition({left, top, width, height: "auto", scale});
    if ( foundry.utils.isEmpty(position) ) return position;
    const clientSettings = game.webrtc.settings.client;
    if ( game.webrtc.settings.verticalDock ) {
      clientSettings.dockWidth = width;
      game.webrtc.settings.set("client", "dockWidth", width);
    }
    return position;
  }

  /* -------------------------------------------- */

  /** @override */
  getData(options={}) {
    const settings = this.webrtc.settings;
    const userSettings = settings.users;

    // Get the sorted array of connected users
    const connectedIds = this.webrtc.client.getConnectedUsers();
    const users = connectedIds.reduce((users, u) => {
      const data = this._getDataForUser(u, userSettings[u]);
      if ( data && !userSettings[u].blocked ) users.push(data);
      return users;
    }, []);
    users.sort(this.constructor._sortUsers);

    // Maximum Z of all user popout windows
    this.maxZ = Math.max(...users.map(u => userSettings[u.user.id].z));

    // Define a dynamic class for the camera dock container which affects its rendered style
    const dockClass = [`camera-position-${settings.client.dockPosition}`];
    if ( !users.some(u => !u.settings.popout) ) dockClass.push("webrtc-dock-empty");
    if ( settings.client.hideDock ) dockClass.push("webrtc-dock-minimized");
    if ( this.hidden ) dockClass.push("hidden");

    // Alter the body class depending on whether the players list is hidden
    const playersVisible = !settings.client.hidePlayerList || settings.client.hideDock;
    document.body.classList.toggle("players-hidden", playersVisible);

    const nameplateModes = AVSettings.NAMEPLATE_MODES;
    const nameplateSetting = settings.client.nameplates ?? nameplateModes.BOTH;

    const nameplates = {
      cssClass: [
        nameplateSetting === nameplateModes.OFF ? "hidden" : "",
        [nameplateModes.PLAYER_ONLY, nameplateModes.CHAR_ONLY].includes(nameplateSetting) ? "noanimate" : ""
      ].filterJoin(" "),
      playerName: [nameplateModes.BOTH, nameplateModes.PLAYER_ONLY].includes(nameplateSetting),
      charname: [nameplateModes.BOTH, nameplateModes.CHAR_ONLY].includes(nameplateSetting)
    };

    // Return data for rendering
    return {
      self: game.user,
      muteAll: settings.muteAll,
      borderColors: settings.client.borderColors,
      dockClass: dockClass.join(" "),
      hidden: this.hidden,
      users, nameplates
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare rendering data for a single user
   * @private
   */
  _getDataForUser(userId, settings) {
    const user = game.users.get(userId);
    if ( !user || !user.active ) return null;
    const charname = user.character ? user.character.name.split(" ")[0] : "";

    // CSS classes for the frame
    const frameClass = settings.popout ? "camera-box-popout" : "camera-box-dock";
    const audioClass = this.webrtc.canUserShareAudio(userId) ? null : "no-audio";
    const videoClass = this.webrtc.canUserShareVideo(userId) ? null : "no-video";

    // Return structured User data
    return {
      user, settings,
      local: user.isSelf,
      charname: user.isGM ? game.i18n.localize("USER.GM") : charname,
      volume: foundry.audio.AudioHelper.volumeToInput(settings.volume),
      cameraViewClass: [frameClass, videoClass, audioClass].filterJoin(" ")
    };
  }

  /* -------------------------------------------- */

  /**
   * A custom sorting function that orders/arranges the user display frames
   * @return {number}
   * @private
   */
  static _sortUsers(a, b) {
    const as = a.settings;
    const bs = b.settings;
    if (as.popout && bs.popout) return as.z - bs.z; // Sort popouts by z-index
    if (as.popout) return -1;                       // Show popout feeds first
    if (bs.popout) return 1;
    if (a.user.isSelf) return -1;                   // Show local feed first
    if (b.user.isSelf) return 1;
    if (a.hasVideo && !b.hasVideo) return -1;       // Show remote users with a camera before those without
    if (b.hasVideo && !a.hasVideo) return 1;
    return a.user.sort - b.user.sort;               // Sort according to user order
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {

    // Display controls when hovering over the video container
    let cvh = this._onCameraViewHover.bind(this);
    html.find(".camera-view").hover(cvh, cvh);

    // Handle clicks on AV control buttons
    html.find(".av-control").click(this._onClickControl.bind(this));

    // Handle volume changes
    html.find(".webrtc-volume-slider").change(this._onVolumeChange.bind(this));

    // Handle user controls.
    this._refreshView(html.find(".user-controls")[0]?.dataset.user);

    // Hide Global permission icons depending on the A/V mode
    const mode = this.webrtc.mode;
    if ( mode === AVSettings.AV_MODES.VIDEO ) html.find('[data-action="toggle-audio"]').hide();
    if ( mode === AVSettings.AV_MODES.AUDIO ) html.find('[data-action="toggle-video"]').hide();

    // Make each popout window draggable
    for ( let popout of this.element.find(".app.camera-view-popout") ) {
      let box = popout.querySelector(".camera-view");
      new CameraPopoutAppWrapper(this, box.dataset.user, $(popout));
    }

    // Listen to the video's srcObjectSet event to set the display mode of the user.
    for ( let video of this.element.find("video") ) {
      const view = video.closest(".camera-view");
      this._refreshView(view.dataset.user);
      video.addEventListener("webrtcVideoSet", ev => {
        const view = video.closest(".camera-view");
        if ( view.dataset.user !== ev.detail ) return;
        this._refreshView(view.dataset.user);
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * On hover in a camera container, show/hide the controls.
   * @event {Event} event   The original mouseover or mouseout hover event
   * @private
   */
  _onCameraViewHover(event) {
    this._toggleControlVisibility(event.currentTarget, event.type === "mouseenter", null);
  }

  /* -------------------------------------------- */

  /**
   * On clicking on a toggle, disable/enable the audio or video stream.
   * @event {MouseEvent} event   The originating click event
   * @private
   */
  async _onClickControl(event) {
    event.preventDefault();

    // Reference relevant data
    const button = event.currentTarget;
    const action = button.dataset.action;
    const userId = button.closest(".camera-view, .user-controls")?.dataset.user;
    const user = game.users.get(userId);
    const settings = this.webrtc.settings;
    const userSettings = settings.getUser(user.id);

    // Handle different actions
    switch ( action ) {

      // Globally block video
      case "block-video":
        if ( !game.user.isGM ) return;
        await user.update({"permissions.BROADCAST_VIDEO": !userSettings.canBroadcastVideo});
        return this._refreshView(userId);

      // Globally block audio
      case "block-audio":
        if ( !game.user.isGM ) return;
        await user.update({"permissions.BROADCAST_AUDIO": !userSettings.canBroadcastAudio});
        return this._refreshView(userId);

      // Hide the user
      case "hide-user":
        if ( user.isSelf ) return;
        await settings.set("client", `users.${user.id}.blocked`, !userSettings.blocked);
        return this.render();

      // Toggle video display
      case "toggle-video":
        if ( !user.isSelf ) return;
        if ( userSettings.hidden && !userSettings.canBroadcastVideo ) {
          return ui.notifications.warn("WEBRTC.WarningCannotEnableVideo", {localize: true});
        }
        await settings.set("client", `users.${user.id}.hidden`, !userSettings.hidden);
        return this._refreshView(userId);

      // Toggle audio output
      case "toggle-audio":
        if ( !user.isSelf ) return;
        if ( userSettings.muted && !userSettings.canBroadcastAudio ) {
          return ui.notifications.warn("WEBRTC.WarningCannotEnableAudio", {localize: true});
        }
        await settings.set("client", `users.${user.id}.muted`, !userSettings.muted);
        return this._refreshView(userId);

      // Toggle mute all peers
      case "mute-peers":
        if ( !user.isSelf ) return;
        await settings.set("client", "muteAll", !settings.client.muteAll);
        return this._refreshView(userId);

      // Disable sending and receiving video
      case "disable-video":
        if ( !user.isSelf ) return;
        await settings.set("client", "disableVideo", !settings.client.disableVideo);
        return this._refreshView(userId);

      // Configure settings
      case "configure":
        return this.webrtc.config.render(true);

      // Toggle popout
      case "toggle-popout":
        await settings.set("client", `users.${user.id}.popout`, !userSettings.popout);
        return this.render();

      // Hide players
      case "toggle-players":
        await settings.set("client", "hidePlayerList", !settings.client.hidePlayerList);
        return this.render();

      // Minimize the dock
      case "toggle-dock":
        await settings.set("client", "hideDock", !settings.client.hideDock);
        return this.render();
    }
  }

  /* -------------------------------------------- */

  /**
   * Change volume control for a stream
   * @param {Event} event   The originating change event from interaction with the range input
   * @private
   */
  _onVolumeChange(event) {
    const input = event.currentTarget;
    const box = input.closest(".camera-view");
    const userId = box.dataset.user;
    let volume = foundry.audio.AudioHelper.inputToVolume(input.value);
    box.getElementsByTagName("video")[0].volume = volume;
    this.webrtc.settings.set("client", `users.${userId}.volume`, volume);
  }

  /* -------------------------------------------- */
  /*  Internal Helpers                            */
  /* -------------------------------------------- */

  /**
   * Dynamically refresh the state of a single camera view
   * @param {string} userId  The ID of the user whose view we want to refresh.
   * @protected
   */
  _refreshView(userId) {
    const view = this.element[0].querySelector(`.camera-view[data-user="${userId}"]`);
    const isSelf = game.user.id === userId;
    const clientSettings = game.webrtc.settings.client;
    const userSettings = game.webrtc.settings.getUser(userId);
    const minimized = clientSettings.hideDock;
    const isVertical = game.webrtc.settings.verticalDock;

    // Identify permissions
    const cbv = game.webrtc.canUserBroadcastVideo(userId);
    const csv = game.webrtc.canUserShareVideo(userId);
    const cba = game.webrtc.canUserBroadcastAudio(userId);
    const csa = game.webrtc.canUserShareAudio(userId);

    // Refresh video display
    const video = view.querySelector("video.user-camera");
    const avatar = view.querySelector("img.user-avatar");
    if ( video && avatar ) {
      const showVideo = csv && (isSelf || !clientSettings.disableVideo) && (!minimized || userSettings.popout);
      video.style.visibility = showVideo ? "visible" : "hidden";
      video.style.display = showVideo ? "block" : "none";
      avatar.style.display = showVideo ? "none" : "unset";
    }

    // Hidden and muted status icons
    view.querySelector(".status-hidden")?.classList.toggle("hidden", csv);
    view.querySelector(".status-muted")?.classList.toggle("hidden", csa);

    // Volume bar and video output volume
    if ( video ) {
      video.volume = userSettings.volume;
      video.muted = isSelf || clientSettings.muteAll; // Mute your own video
    }
    const volBar = this.element[0].querySelector(`[data-user="${userId}"] .volume-bar`);
    if ( volBar ) {
      const displayBar = (userId !== game.user.id) && cba;
      volBar.style.display = displayBar ? "block" : "none";
      volBar.disabled = !displayBar;
    }

    // Control toggle states
    const actions = {
      "block-video": {state: !cbv, display: game.user.isGM && !isSelf},
      "block-audio": {state: !cba, display: game.user.isGM && !isSelf},
      "hide-user": {state: !userSettings.blocked, display: !isSelf},
      "toggle-video": {state: !csv, display: isSelf && !minimized},
      "toggle-audio": {state: !csa, display: isSelf},
      "mute-peers": {state: clientSettings.muteAll, display: isSelf},
      "disable-video": {state: clientSettings.disableVideo, display: isSelf && !minimized},
      "toggle-players": {state: !clientSettings.hidePlayerList, display: isSelf && !minimized && isVertical},
      "toggle-dock": {state: !clientSettings.hideDock, display: isSelf}
    };
    const toggles = this.element[0].querySelectorAll(`[data-user="${userId}"] .av-control.toggle`);
    for ( let button of toggles ) {
      const action = button.dataset.action;
      if ( !(action in actions) ) continue;
      const state = actions[action].state;
      const displayed = actions[action].display;
      button.style.display = displayed ? "block" : "none";
      button.enabled = displayed;
      button.children[0].classList.remove(this._getToggleIcon(action, !state));
      button.children[0].classList.add(this._getToggleIcon(action, state));
      button.dataset.tooltip = this._getToggleTooltip(action, state);
    }
  }

  /* -------------------------------------------- */

  /**
   * Render changes needed to the PlayerList ui.
   * Show/Hide players depending on option.
   * @private
   */
  _setPlayerListVisibility() {
    const hidePlayerList = this.webrtc.settings.client.hidePlayerList;
    const players = document.getElementById("players");
    const top = document.getElementById("ui-top");
    if ( players ) players.classList.toggle("hidden", hidePlayerList);
    if ( top ) top.classList.toggle("offset", !hidePlayerList);
  }

  /* -------------------------------------------- */

  /**
   * Get the icon class that should be used for various action buttons with different toggled states.
   * The returned icon should represent the visual status of the NEXT state (not the CURRENT state).
   *
   * @param {string} action     The named av-control button action
   * @param {boolean} state     The CURRENT action state.
   * @returns {string}          The icon that represents the NEXT action state.
   * @protected
   */
  _getToggleIcon(action, state) {
    const clientSettings = game.webrtc.settings.client;
    const dockPositions = AVSettings.DOCK_POSITIONS;
    const dockIcons = {
      [dockPositions.TOP]: {collapse: "down", expand: "up"},
      [dockPositions.RIGHT]: {collapse: "left", expand: "right"},
      [dockPositions.BOTTOM]: {collapse: "up", expand: "down"},
      [dockPositions.LEFT]: {collapse: "right", expand: "left"}
    }[clientSettings.dockPosition];
    const actionMapping = {
      "block-video": ["fa-video", "fa-video-slash"],            // True means "blocked"
      "block-audio": ["fa-microphone", "fa-microphone-slash"],  // True means "blocked"
      "hide-user": ["fa-eye", "fa-eye-slash"],
      "toggle-video": ["fa-camera-web", "fa-camera-web-slash"], // True means "enabled"
      "toggle-audio": ["fa-microphone", "fa-microphone-slash"], // True means "enabled"
      "mute-peers": ["fa-volume-up", "fa-volume-mute"],         // True means "muted"
      "disable-video": ["fa-video", "fa-video-slash"],
      "toggle-players": ["fa-caret-square-right", "fa-caret-square-left"], // True means "displayed"
      "toggle-dock": [`fa-caret-square-${dockIcons.collapse}`, `fa-caret-square-${dockIcons.expand}`]
    };
    const icons = actionMapping[action];
    return icons ? icons[state ? 1: 0] : null;
  }

  /* -------------------------------------------- */

  /**
   * Get the text title that should be used for various action buttons with different toggled states.
   * The returned title should represent the tooltip of the NEXT state (not the CURRENT state).
   *
   * @param {string} action     The named av-control button action
   * @param {boolean} state     The CURRENT action state.
   * @returns {string}          The icon that represents the NEXT action state.
   * @protected
   */
  _getToggleTooltip(action, state) {
    const actionMapping = {
      "block-video": ["BlockUserVideo", "AllowUserVideo"],      // True means "blocked"
      "block-audio": ["BlockUserAudio", "AllowUserAudio"],      // True means "blocked"
      "hide-user": ["ShowUser", "HideUser"],
      "toggle-video": ["DisableMyVideo", "EnableMyVideo"],      // True means "enabled"
      "toggle-audio": ["DisableMyAudio", "EnableMyAudio"],      // True means "enabled"
      "mute-peers": ["MutePeers", "UnmutePeers"],               // True means "muted"
      "disable-video": ["DisableAllVideo", "EnableVideo"],
      "toggle-players": ["ShowPlayers", "HidePlayers"],         // True means "displayed"
      "toggle-dock": ["ExpandDock", "MinimizeDock"]
    };
    const labels = actionMapping[action];
    return game.i18n.localize(`WEBRTC.Tooltip${labels ? labels[state ? 1 : 0] : ""}`);
  }

  /* -------------------------------------------- */

  /**
   * Show or hide UI control elements
   * This replaces the use of jquery.show/hide as it simply adds a class which has display:none
   * which allows us to have elements with display:flex which can be hidden then shown without
   * breaking their display style.
   * This will show/hide the toggle buttons, volume controls and overlay sidebars
   * @param {jQuery} container    The container for which to show/hide control elements
   * @param {boolean} show        Whether to show or hide the controls
   * @param {string} selector     Override selector to specify which controls to show or hide
   * @private
   */
  _toggleControlVisibility(container, show, selector) {
    selector = selector || `.control-bar`;
    container.querySelectorAll(selector).forEach(c => c.classList.toggle("hidden", !show));
  }
}

/**
 * A Dialog subclass which allows the user to configure export options for a Folder
 * @extends {Dialog}
 */
class FolderExport extends Dialog {

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('select[name="pack"]').change(this._onPackChange.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the selected pack by updating the dropdown of folders available.
   * @param {Event} event   The input change event
   */
  _onPackChange(event) {
    const select = this.element.find('select[name="folder"]')[0];
    const pack = game.packs.get(event.target.value);
    if ( !pack ) {
      select.disabled = true;
      return;
    }
    const folders = pack._formatFolderSelectOptions();
    select.disabled = folders.length === 0;
    select.innerHTML = HandlebarsHelpers.selectOptions(folders, {hash: {
      blank: "",
      nameAttr: "id",
      labelAttr: "name"
    }});
  }
}

/**
 * An application responsible for configuring how dice are rolled and evaluated.
 */
class DiceConfig extends FormApplication {
  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "dice-config",
      template: "templates/dice/config.html",
      title: "DICE.CONFIG.Title",
      width: 500
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData(options={}) {
    const context = super.getData(options);
    const { methods, dice } = CONFIG.Dice.fulfillment;
    if ( !game.user.hasPermission("MANUAL_ROLLS") ) delete methods.manual;
    const config = game.settings.get("core", "diceConfiguration");
    context.methods = methods;
    context.dice = Object.entries(dice).map(([k, { label, icon }]) => {
      return { label, icon, denomination: k, method: config[k] || "" };
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    const config = game.settings.get("core", "diceConfiguration");
    foundry.utils.mergeObject(config, formData);
    return game.settings.set("core", "diceConfiguration", config);
  }
}

/**
 * @typedef {FormApplicationOptions} DrawingConfigOptions
 * @property {boolean} [configureDefault=false]  Configure the default drawing settings, instead of a specific Drawing
 */

/**
 * The Application responsible for configuring a single Drawing document within a parent Scene.
 * @extends {DocumentSheet}
 *
 * @param {Drawing} drawing               The Drawing object being configured
 * @param {DrawingConfigOptions} options  Additional application rendering options
 */
class DrawingConfig extends DocumentSheet {
  /**
   * @override
   * @returns {DrawingConfigOptions}
   */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "drawing-config",
      template: "templates/scene/drawing-config.html",
      width: 480,
      height: "auto",
      configureDefault: false,
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "position"}]
    });
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    if ( this.options.configureDefault ) return game.i18n.localize("DRAWING.ConfigDefaultTitle");
    return super.title;
  }

  /* -------------------------------------------- */

  /** @override */
  getData(options={}) {

    // Submit text
    let submit;
    if ( this.options.configureDefault ) submit = "DRAWING.SubmitDefault";
    else submit = this.document.id ? "DRAWING.SubmitUpdate" : "DRAWING.SubmitCreate";

    // Rendering context
    return {
      author: this.document.author?.name || "",
      isDefault: this.options.configureDefault,
      fillTypes: Object.entries(CONST.DRAWING_FILL_TYPES).reduce((obj, v) => {
        obj[v[1]] = `DRAWING.FillType${v[0].titleCase()}`;
        return obj;
      }, {}),
      scaledBezierFactor: this.document.bezierFactor * 2,
      fontFamilies: FontConfig.getAvailableFontChoices(),
      drawingRoles: {
        object: "DRAWING.Object",
        information: "DRAWING.Information"
      },
      currentRole: this.document.interface ? "information" : "object",
      object: this.document.toObject(),
      options: this.options,
      gridUnits: this.document.parent?.grid.units || canvas.scene.grid.units || game.i18n.localize("GridUnits"),
      userColor: game.user.color,
      submitText: submit
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    if ( !this.object.isOwner ) throw new Error("You do not have the ability to configure this Drawing object.");

    // Un-scale the bezier factor
    formData.bezierFactor /= 2;

    // Configure the default Drawing settings
    if ( this.options.configureDefault ) {
      formData = foundry.utils.expandObject(formData);
      const defaults = DrawingDocument.cleanData(formData, {partial: true});
      return game.settings.set("core", DrawingsLayer.DEFAULT_CONFIG_SETTING, defaults);
    }

    // Assign location
    formData.interface = (formData.drawingRole === "information");
    delete formData.drawingRole;

    // Rescale dimensions if needed
    const shape = this.object.shape;
    const w = formData["shape.width"];
    const h = formData["shape.height"];
    if ( shape && ((w !== shape.width) || (h !== shape.height)) ) {
      const dx = w - shape.width;
      const dy = h - shape.height;
      formData = foundry.utils.expandObject(formData);
      formData.shape.width = shape.width;
      formData.shape.height = shape.height;
      foundry.utils.mergeObject(formData, Drawing.rescaleDimensions(formData, dx, dy));
    }

    // Create or update a Drawing
    if ( this.object.id ) return this.object.update(formData);
    return this.object.constructor.create(formData);
  }

  /* -------------------------------------------- */

  /** @override */
  async close(options) {
    await super.close(options);
    if ( this.preview ) {
      this.preview.removeChildren();
      this.preview = null;
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('button[name="reset"]').click(this._onResetDefaults.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Reset the user Drawing configuration settings to their default values
   * @param {PointerEvent} event      The originating mouse-click event
   * @protected
   */
  _onResetDefaults(event) {
    event.preventDefault();
    this.object = DrawingDocument.fromSource({});
    this.render();
  }
}

/**
 * An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Drawing objects.
 * The DrawingHUD implementation can be configured and replaced via {@link CONFIG.Drawing.hudClass}.
 * @extends {BasePlaceableHUD<Drawing, DrawingDocument, DrawingsLayer>}
 */
class DrawingHUD extends BasePlaceableHUD {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "drawing-hud",
      template: "templates/hud/drawing-hud.html"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData(options={}) {
    const {locked, hidden} = this.object.document;
    return foundry.utils.mergeObject(super.getData(options), {
      lockedClass: locked ? "active" : "",
      visibilityClass: hidden ? "active" : ""
    });
  }

  /* -------------------------------------------- */

  /** @override */
  setPosition(options) {
    let {x, y, width, height} = this.object.frame.bounds;
    const c = 70;
    const p = 10;
    const position = {
      width: width + (c * 2) + (p * 2),
      height: height + (p * 2),
      left: x + this.object.x - c - p,
      top: y + this.object.y - p
    };
    this.element.css(position);
  }
}

/**
 * The Application responsible for configuring a single Note document within a parent Scene.
 * @param {NoteDocument} note               The Note object for which settings are being configured
 * @param {DocumentSheetOptions} [options]  Additional Application configuration options
 */
class NoteConfig extends DocumentSheet {

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      title: game.i18n.localize("NOTE.ConfigTitle"),
      template: "templates/scene/note-config.html",
      width: 480
    });
  }

  /* -------------------------------------------- */

  /** @override */
  getData(options={}) {
    const data = super.getData(options);
    if ( !this.object.id ) data.data.global = !canvas.scene.tokenVision;
    const entry = game.journal.get(this.object.entryId);
    const pages = entry?.pages.contents.sort((a, b) => a.sort - b.sort);
    const icons = Object.entries(CONFIG.JournalEntry.noteIcons).map(([label, src]) => {
      return {label, src};
    }).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
    icons.unshift({label: game.i18n.localize("NOTE.Custom"), src: ""});
    const customIcon = !Object.values(CONFIG.JournalEntry.noteIcons).includes(this.document.texture.src);
    const icon = {
      selected: customIcon ? "" : this.document.texture.src,
      custom: customIcon ? this.document.texture.src : ""
    };
    return foundry.utils.mergeObject(data, {
      icon, icons,
      label: this.object.label,
      entry: entry || {},
      pages: pages || [],
      entries: game.journal.filter(e => e.isOwner).sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang)),
      fontFamilies: FontConfig.getAvailableFontChoices(),
      textAnchors: Object.entries(CONST.TEXT_ANCHOR_POINTS).reduce((obj, e) => {
        obj[e[1]] = game.i18n.localize(`JOURNAL.Anchor${e[0].titleCase()}`);
        return obj;
      }, {}),
      gridUnits: this.document.parent.grid.units || game.i18n.localize("GridUnits"),
      submitText: game.i18n.localize(this.id ? "NOTE.Update" : "NOTE.Create")
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    this._updateCustomIcon();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onChangeInput(event) {
    this._updateCustomIcon();
    if ( event.currentTarget.name === "entryId" ) this._updatePageList();
    return super._onChangeInput(event);
  }

  /* -------------------------------------------- */

  /**
   * Update disabled state of the custom icon field.
   * @protected
   */
  _updateCustomIcon() {
    const selected = this.form["icon.selected"];
    this.form["icon.custom"].disabled = selected.value.length;
  }

  /* -------------------------------------------- */

  /**
   * Update the list of pages.
   * @protected
   */
  _updatePageList() {
    const entryId = this.form.elements.entryId?.value;
    const pages = game.journal.get(entryId)?.pages.contents.sort((a, b) => a.sort - b.sort) ?? [];
    const options = pages.map(page => {
      const selected = (entryId === this.object.entryId) && (page.id === this.object.pageId);
      return `<option value="${page.id}"${selected ? " selected" : ""}>${page.name}</option>`;
    });
    this.form.elements.pageId.innerHTML = `<option></option>${options}`;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getSubmitData(updateData={}) {
    const data = super._getSubmitData(updateData);
    data["texture.src"] = data["icon.selected"] || data["icon.custom"];
    delete data["icon.selected"];
    delete data["icon.custom"];
    return data;
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    if ( this.object.id ) return this.object.update(formData);
    else return this.object.constructor.create(formData, {parent: canvas.scene});
  }

  /* -------------------------------------------- */

  /** @override */
  async close(options) {
    if ( !this.object.id ) canvas.notes.clearPreviewContainer();
    return super.close(options);
  }
}

/**
 * The Application responsible for configuring a single Tile document within a parent Scene.
 * @param {Tile} tile                    The Tile object being configured
 * @param {DocumentSheetOptions} [options]  Additional application rendering options
 */
class TileConfig extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "tile-config",
      title: game.i18n.localize("TILE.ConfigTitle"),
      template: "templates/scene/tile-config.html",
      width: 420,
      height: "auto",
      submitOnChange: true,
      tabs: [{navSelector: ".tabs", contentSelector: "form", initial: "basic"}]
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async close(options={}) {

    // If the config was closed without saving, reset the initial display of the Tile
    if ( !options.force ) {
      this.document.reset();
      if ( this.document.object?.destroyed === false ) {
        this.document.object.refresh();
      }
    }

    // Remove the preview tile and close
    const layer = this.object.layer;
    layer.clearPreviewContainer();
    return super.close(options);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    const data = super.getData(options);
    data.submitText = game.i18n.localize(this.object.id ? "TILE.SubmitUpdate" : "TILE.SubmitCreate");
    data.occlusionModes = Object.entries(CONST.OCCLUSION_MODES).reduce((obj, e) => {
      obj[e[1]] = game.i18n.localize(`TILE.OcclusionMode${e[0].titleCase()}`);
      return obj;
    }, {});
    data.gridUnits = this.document.parent.grid.units || game.i18n.localize("GridUnits");
    return data;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onChangeInput(event) {

    // Handle form element updates
    const el = event.target;
    if ( (el.type === "color") && el.dataset.edit ) this._onChangeColorPicker(event);
    else if ( el.type === "range" ) this._onChangeRange(event);

    // Update preview object
    const fdo = new FormDataExtended(this.form).object;

    // To allow a preview without glitches
    fdo.width = Math.abs(fdo.width);
    fdo.height = Math.abs(fdo.height);

    // Handle tint exception
    let tint = fdo["texture.tint"];
    if ( !foundry.data.validators.isColorString(tint) ) fdo["texture.tint"] = "#ffffff";
    fdo["texture.tint"] = Color.from(fdo["texture.tint"]);

    // Update preview object
    foundry.utils.mergeObject(this.document, foundry.utils.expandObject(fdo));
    this.document.object.refresh();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {
    if ( this.document.id ) return this.document.update(formData);
    else return this.document.constructor.create(formData, {
      parent: this.document.parent,
      pack: this.document.pack
    });
  }
}

/**
 * An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Tile objects.
 * The TileHUD implementation can be configured and replaced via {@link CONFIG.Tile.hudClass}.
 * @extends {BasePlaceableHUD<Tile, TileDocument, TilesLayer>}
 */
class TileHUD extends BasePlaceableHUD {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "tile-hud",
      template: "templates/hud/tile-hud.html"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData(options={}) {
    const {locked, hidden} = this.document;
    const {isVideo, sourceElement} = this.object;
    const isPlaying = isVideo && !sourceElement.paused && !sourceElement.ended;
    return foundry.utils.mergeObject(super.getData(options), {
      isVideo: isVideo,
      lockedClass: locked ? "active" : "",
      visibilityClass: hidden ? "active" : "",
      videoIcon: isPlaying ? "fas fa-pause" : "fas fa-play",
      videoTitle: game.i18n.localize(isPlaying ? "HUD.TilePause" : "HUD.TilePlay")
    });
  }

  /* -------------------------------------------- */

  /** @override */
  setPosition(options) {
    let {x, y, width, height} = this.object.frame.bounds;
    const c = 70;
    const p = 10;
    const position = {
      width: width + (c * 2) + (p * 2),
      height: height + (p * 2),
      left: x + this.object.x - c - p,
      top: y + this.object.y - p
    };
    this.element.css(position);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickControl(event) {
    super._onClickControl(event);
    if ( event.defaultPrevented ) return;
    const button = event.currentTarget;
    switch ( button.dataset.action ) {
      case "video":
        return this.#onControlVideo(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Control video playback by toggling play or paused state for a video Tile.
   * @param {PointerEvent} event
   */
  #onControlVideo(event) {
    const src = this.object.sourceElement;
    const icon = event.currentTarget.children[0];
    const isPlaying = !src.paused && !src.ended;

    // Intercepting state change if the source is not looping and not playing
    if ( !src.loop && !isPlaying ) {
      const self = this;
      src.onpause = () => {
        if ( self.object?.sourceElement ) {
          icon.classList.replace("fa-pause", "fa-play");
          self.render();
        }
        src.onpause = null;
      };
    }

    // Update the video playing state
    return this.object.document.update({"video.autoplay": false}, {
      diff: false,
      playVideo: !isPlaying,
      offset: src.ended ? 0 : null
    });
  }
}

/**
 * The Application responsible for configuring a single Token document within a parent Scene.
 * @param {TokenDocument|Actor} object          The {@link TokenDocument} being configured or an {@link Actor} for whom
 *                                              to configure the {@link PrototypeToken}
 * @param {FormApplicationOptions} [options]    Application configuration options.
 */
class TokenConfig extends DocumentSheet {
  constructor(object, options) {
    super(object, options);

    /**
     * The placed Token object in the Scene
     * @type {TokenDocument}
     */
    this.token = this.object;

    /**
     * A reference to the Actor which the token depicts
     * @type {Actor}
     */
    this.actor = this.object.actor;

    // Configure options
    if ( this.isPrototype ) this.options.sheetConfig = false;
  }

  /**
   * Maintain a copy of the original to show a real-time preview of changes.
   * @type {TokenDocument|PrototypeToken}
   */
  preview;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["sheet", "token-sheet"],
      template: "templates/scene/token-config.html",
      width: 480,
      height: "auto",
      tabs: [
        {navSelector: '.tabs[data-group="main"]', contentSelector: "form", initial: "character"},
        {navSelector: '.tabs[data-group="light"]', contentSelector: '.tab[data-tab="light"]', initial: "basic"},
        {navSelector: '.tabs[data-group="vision"]', contentSelector: '.tab[data-tab="vision"]', initial: "basic"}
      ],
      viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER,
      sheetConfig: true
    });
  }

  /* -------------------------------------------- */

  /**
   * A convenience accessor to test whether we are configuring the prototype Token for an Actor.
   * @type {boolean}
   */
  get isPrototype() {
    return this.object instanceof foundry.data.PrototypeToken;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get id() {
    if ( this.isPrototype ) return `${this.constructor.name}-${this.actor.uuid}`;
    else return super.id;
  }

  /* -------------------------------------------- */


  /** @inheritdoc */
  get title() {
    if ( this.isPrototype ) return `${game.i18n.localize("TOKEN.TitlePrototype")}: ${this.actor.name}`;
    return `${game.i18n.localize("TOKEN.Title")}: ${this.token.name}`;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  render(force=false, options={}) {
    if ( this.isPrototype ) this.object.actor.apps[this.appId] = this;
    return super.render(force, options);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _render(force, options={}) {
    await this._handleTokenPreview(force, options);
    return super._render(force, options);
  }

  /* -------------------------------------------- */

  /**
   * Handle preview with a token.
   * @param {boolean} force
   * @param {object} options
   * @returns {Promise<void>}
   * @protected
   */
  async _handleTokenPreview(force, options={}) {
    const states = Application.RENDER_STATES;
    if ( force && [states.CLOSED, states.NONE].includes(this._state) ) {
      if ( this.isPrototype ) {
        this.preview = this.object.clone();
        return;
      }
      if ( !this.document.object ) {
        this.preview = null;
        return;
      }
      if ( !this.preview ) {
        const clone = this.document.object.clone({}, {keepId: true});
        this.preview = clone.document;
        clone.control({releaseOthers: true});
      }
      await this.preview.object.draw();
      this.document.object.renderable = false;
      this.document.object.initializeSources({deleted: true});
      this.preview.object.layer.preview.addChild(this.preview.object);
      this._previewChanges();
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _canUserView(user) {
    const canView = super._canUserView(user);
    return canView && game.user.can("TOKEN_CONFIGURE");
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async getData(options={}) {
    const alternateImages = await this._getAlternateTokenImages();
    const usesTrackableAttributes = !foundry.utils.isEmpty(CONFIG.Actor.trackableAttributes);
    const attributeSource = (this.actor?.system instanceof foundry.abstract.DataModel) && usesTrackableAttributes
      ? this.actor?.type
      : this.actor?.system;
    const attributes = TokenDocument.implementation.getTrackedAttributes(attributeSource);
    const canBrowseFiles = game.user.hasPermission("FILES_BROWSE");

    // Prepare Token data
    const doc = this.preview ?? this.document;
    const source = doc.toObject();
    const sourceDetectionModes = new Set(source.detectionModes.map(m => m.id));
    const preparedDetectionModes = doc.detectionModes.filter(m => !sourceDetectionModes.has(m.id));

    // Return rendering context
    return {
      fields: this.document.schema.fields, // Important to use the true document schema,
      lightFields: this.document.schema.fields.light.fields,
      cssClasses: [this.isPrototype ? "prototype" : null].filter(c => !!c).join(" "),
      isPrototype: this.isPrototype,
      hasAlternates: !foundry.utils.isEmpty(alternateImages),
      alternateImages: alternateImages,
      object: source,
      options: this.options,
      gridUnits: (this.isPrototype ? "" : this.document.parent?.grid.units) || game.i18n.localize("GridUnits"),
      barAttributes: TokenDocument.implementation.getTrackedAttributeChoices(attributes),
      bar1: doc.getBarAttribute?.("bar1"),
      bar2: doc.getBarAttribute?.("bar2"),
      colorationTechniques: AdaptiveLightingShader.SHADER_TECHNIQUES,
      visionModes: Object.values(CONFIG.Canvas.visionModes).filter(f => f.tokenConfig),
      detectionModes: Object.values(CONFIG.Canvas.detectionModes).filter(f => f.tokenConfig),
      preparedDetectionModes,
      displayModes: Object.entries(CONST.TOKEN_DISPLAY_MODES).reduce((obj, e) => {
        obj[e[1]] = game.i18n.localize(`TOKEN.DISPLAY_${e[0]}`);
        return obj;
      }, {}),
      hexagonalShapes: Object.entries(CONST.TOKEN_HEXAGONAL_SHAPES).reduce((obj, [k, v]) => {
        obj[v] = game.i18n.localize(`TOKEN.HEXAGONAL_SHAPE_${k}`);
        return obj;
      }, {}),
      showHexagonalShapes: this.isPrototype || !doc.parent || doc.parent.grid.isHexagonal,
      actors: game.actors.reduce((actors, a) => {
        if ( !a.isOwner ) return actors;
        actors.push({_id: a.id, name: a.name});
        return actors;
      }, []).sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang)),
      dispositions: Object.entries(CONST.TOKEN_DISPOSITIONS).reduce((obj, e) => {
        obj[e[1]] = game.i18n.localize(`TOKEN.DISPOSITION.${e[0]}`);
        return obj;
      }, {}),
      lightAnimations: CONFIG.Canvas.lightAnimations,
      isGM: game.user.isGM,
      randomImgEnabled: this.isPrototype && (canBrowseFiles || doc.randomImg),
      scale: Math.abs(doc.texture.scaleX),
      mirrorX: doc.texture.scaleX < 0,
      mirrorY: doc.texture.scaleY < 0,
      textureFitModes: CONST.TEXTURE_DATA_FIT_MODES.reduce((obj, fit) => {
        obj[fit] = game.i18n.localize(`TEXTURE_DATA.FIT.${fit}`);
        return obj;
      }, {}),
      ringEffectsInput: this.#ringEffectsInput.bind(this)
    };
  }

  /* --------------------------------------------- */

  /** @inheritdoc */
  async _renderInner(...args) {
    await loadTemplates([
      "templates/scene/parts/token-lighting.hbs",
      "templates/scene/parts/token-vision.html",
      "templates/scene/parts/token-resources.html"
    ]);
    return super._renderInner(...args);
  }

  /* -------------------------------------------- */

  /**
   * Render the Token ring effects input using a multi-checkbox element.
   * @param {NumberField} field             The ring effects field
   * @param {FormInputConfig} inputConfig   Form input configuration
   * @returns {HTMLMultiCheckboxElement}
   */
  #ringEffectsInput(field, inputConfig) {
    const options = [];
    const value = [];
    for ( const [effectName, effectValue] of Object.entries(CONFIG.Token.ring.ringClass.effects) ) {
      const localization = CONFIG.Token.ring.effects[effectName];
      if ( (effectName === "DISABLED") || (effectName === "ENABLED") || !localization ) continue;
      options.push({value: effectName, label: game.i18n.localize(localization)});
      if ( (inputConfig.value & effectValue) !== 0 ) value.push(effectName);
    }
    Object.assign(inputConfig, {name: field.fieldPath, options, value, type: "checkboxes"});
    return foundry.applications.fields.createMultiSelectInput(inputConfig);
  }

  /* -------------------------------------------- */

  /**
   * Get an Object of image paths and filenames to display in the Token sheet
   * @returns {Promise<object>}
   * @private
   */
  async _getAlternateTokenImages() {
    if ( !this.actor?.prototypeToken.randomImg ) return {};
    const alternates = await this.actor.getTokenImages();
    return alternates.reduce((obj, img) => {
      obj[img] = img.split("/").pop();
      return obj;
    }, {});
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".action-button").click(this._onClickActionButton.bind(this));
    html.find(".bar-attribute").change(this._onBarChange.bind(this));
    html.find(".alternate-images").change(ev => ev.target.form["texture.src"].value = ev.target.value);
    html.find("button.assign-token").click(this._onAssignToken.bind(this));
    this._disableEditImage();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async close(options={}) {
    const states = Application.RENDER_STATES;
    if ( options.force || [states.RENDERED, states.ERROR].includes(this._state) ) {
      this._resetPreview();
    }
    await super.close(options);
    if ( this.isPrototype ) delete this.object.actor.apps?.[this.appId];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getSubmitData(updateData={}) {
    const formData = foundry.utils.expandObject(super._getSubmitData(updateData));

    // Prototype Token unpacking
    if ( this.document instanceof foundry.data.PrototypeToken ) {
      Object.assign(formData, formData.prototypeToken);
      delete formData.prototypeToken;
    }

    // Mirror token scale
    if ( "scale" in formData ) {
      formData.texture.scaleX = formData.scale * (formData.mirrorX ? -1 : 1);
      formData.texture.scaleY = formData.scale * (formData.mirrorY ? -1 : 1);
    }
    ["scale", "mirrorX", "mirrorY"].forEach(k => delete formData[k]);

    // Token Ring Effects
    if ( Array.isArray(formData.ring?.effects) ) {
      const TRE = CONFIG.Token.ring.ringClass.effects;
      let effects = formData.ring.enabled ? TRE.ENABLED : TRE.DISABLED;
      for ( const effectName of formData.ring.effects ) {
        const v = TRE[effectName] ?? 0;
        effects |= v;
      }
      formData.ring.effects = effects;
    }

    // Clear detection modes array
    formData.detectionModes ??= [];

    // Treat "None" as null for bar attributes
    formData.bar1.attribute ||= null;
    formData.bar2.attribute ||= null;
    return foundry.utils.flattenObject(formData);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onChangeInput(event) {
    await super._onChangeInput(event);

    // Disable image editing for wildcards
    this._disableEditImage();

    // Pre-populate vision mode defaults
    const element = event.target;
    if ( element.name === "sight.visionMode" ) {
      const visionDefaults = CONFIG.Canvas.visionModes[element.value]?.vision?.defaults || {};
      const update = fieldName => {
        const field = this.form.querySelector(`[name="sight.${fieldName}"]`);
        if ( fieldName in visionDefaults ) {
          const value = visionDefaults[fieldName];
          if ( value === undefined ) return;
          if ( field.type === "checkbox" ) {
            field.checked = value;
          } else if ( field.type === "range" ) {
            field.value = value;
            const rangeValue = field.parentNode.querySelector(".range-value");
            if ( rangeValue ) rangeValue.innerText = value;
          } else if ( field.classList.contains("color") ) {
            field.value = value;
            const colorInput = field.parentNode.querySelector('input[type="color"]');
            if ( colorInput ) colorInput.value = value;
          } else {
            field.value = value;
          }
        }
      };
      for ( const fieldName of ["color", "attenuation", "brightness", "saturation", "contrast"] ) update(fieldName);
    }

    // Preview token changes
    const previewData = this._getSubmitData();
    this._previewChanges(previewData);
  }

  /* -------------------------------------------- */

  /**
   * Mimic changes to the Token document as if they were true document updates.
   * @param {object} [change]  The change to preview.
   * @protected
   */
  _previewChanges(change) {
    if ( !this.preview ) return;
    if ( change ) {
      change = {...change};
      delete change.actorId;
      delete change.actorLink;
      this.preview.updateSource(change);
    }
    if ( !this.isPrototype && (this.preview.object?.destroyed === false) ) {
      this.preview.object.initializeSources();
      this.preview.object.renderFlags.set({refresh: true});
    }
  }

  /* -------------------------------------------- */

  /**
   * Reset the temporary preview of the Token when the form is submitted or closed.
   * @protected
   */
  _resetPreview() {
    if ( !this.preview ) return;
    if ( this.isPrototype ) return this.preview = null;
    if ( this.preview.object?.destroyed === false ) {
      this.preview.object.destroy({children: true});
    }
    this.preview.baseActor?._unregisterDependentToken(this.preview);
    this.preview = null;
    if ( this.document.object?.destroyed === false ) {
      this.document.object.renderable = true;
      this.document.object.initializeSources();
      this.document.object.control();
      this.document.object.renderFlags.set({refresh: true});
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {
    this._resetPreview();
    return this.token.update(formData);
  }

  /* -------------------------------------------- */

  /**
   * Handle Token assignment requests to update the default prototype Token
   * @param {MouseEvent} event  The left-click event on the assign token button
   * @private
   */
  async _onAssignToken(event) {
    event.preventDefault();

    // Get controlled Token data
    let tokens = canvas.ready ? canvas.tokens.controlled : [];
    if ( tokens.length !== 1 ) {
      ui.notifications.warn("TOKEN.AssignWarn", {localize: true});
      return;
    }
    const token = tokens.pop().document.toObject();
    token.tokenId = token.x = token.y = null;
    token.randomImg = this.form.elements.randomImg.checked;
    if ( token.randomImg ) delete token.texture.src;

    // Update the prototype token for the actor using the existing Token instance
    await this.actor.update({prototypeToken: token}, {diff: false, recursive: false, noHook: true});
    ui.notifications.info(game.i18n.format("TOKEN.AssignSuccess", {name: this.actor.name}));
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the attribute bar in the drop-down selector to update the default current and max value
   * @param {Event} event  The select input change event
   * @private
   */
  async _onBarChange(event) {
    const form = event.target.form;
    const doc = this.preview ?? this.document;
    const attr = doc.getBarAttribute("", {alternative: event.target.value});
    const bar = event.target.name.split(".").shift();
    form.querySelector(`input.${bar}-value`).value = attr !== null ? attr.value : "";
    form.querySelector(`input.${bar}-max`).value = ((attr !== null) && (attr.type === "bar")) ? attr.max : "";
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on a token configuration sheet action button
   * @param {PointerEvent} event    The originating click event
   * @protected
   */
  _onClickActionButton(event) {
    event.preventDefault();
    const button = event.currentTarget;
    const action = button.dataset.action;
    game.tooltip.deactivate();

    // Get pending changes to modes
    const modes = Object.values(foundry.utils.expandObject(this._getSubmitData())?.detectionModes || {});

    // Manipulate the array
    switch ( action ) {
      case "addDetectionMode":
        this._onAddDetectionMode(modes);
        break;
      case "removeDetectionMode":
        const idx = button.closest(".detection-mode").dataset.index;
        this._onRemoveDetectionMode(Number(idx), modes);
        break;
    }

    this._previewChanges({detectionModes: modes});
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle adding a detection mode.
   * @param {object[]} modes  The existing detection modes.
   * @protected
   */
  _onAddDetectionMode(modes) {
    modes.push({id: "", range: 0, enabled: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle removing a detection mode.
   * @param {number} index    The index of the detection mode to remove.
   * @param {object[]} modes  The existing detection modes.
   * @protected
   */
  _onRemoveDetectionMode(index, modes) {
    modes.splice(index, 1);
  }

  /* -------------------------------------------- */

  /**
   * Disable the user's ability to edit the token image field if wildcard images are enabled and that user does not have
   * file browser permissions.
   * @private
   */
  _disableEditImage() {
    const img = this.form.querySelector('[name="texture.src"]');
    const randomImg = this.form.querySelector('[name="randomImg"]');
    if ( randomImg ) img.disabled = !game.user.hasPermission("FILES_BROWSE") && randomImg.checked;
  }
}

/**
 * A sheet that alters the values of the default Token configuration used when new Token documents are created.
 * @extends {TokenConfig}
 */
class DefaultTokenConfig extends TokenConfig {
  constructor(object, options) {
    const setting = game.settings.get("core", DefaultTokenConfig.SETTING);
    const cls = getDocumentClass("Token");
    object = new cls({name: "Default Token", ...setting}, {strict: false});
    super(object, options);
  }

  /**
   * The named world setting that stores the default Token configuration
   * @type {string}
   */
  static SETTING = "defaultToken";

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "templates/scene/default-token-config.html",
      sheetConfig: false
    });
  }

  /* --------------------------------------------- */

  /** @inheritdoc */
  get id() {
    return "default-token-config";
  }

  /* --------------------------------------------- */

  /** @inheritdoc */
  get title() {
    return game.i18n.localize("SETTINGS.DefaultTokenN");
  }

  /* -------------------------------------------- */

  /** @override */
  get isEditable() {
    return game.user.can("SETTINGS_MODIFY");
  }

  /* -------------------------------------------- */

  /** @override */
  _canUserView(user) {
    return user.can("SETTINGS_MODIFY");
  }

  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    const context = await super.getData(options);
    return Object.assign(context, {
      object: this.token.toObject(false),
      isDefault: true,
      barAttributes: TokenDocument.implementation.getTrackedAttributeChoices(),
      bar1: this.token.bar1,
      bar2: this.token.bar2
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getSubmitData(updateData = {}) {
    const formData = foundry.utils.expandObject(super._getSubmitData(updateData));
    formData.light.color = formData.light.color || undefined;
    formData.bar1.attribute = formData.bar1.attribute || null;
    formData.bar2.attribute = formData.bar2.attribute || null;
    return formData;
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {

    // Validate the default data
    try {
      this.object.updateSource(formData);
      formData = foundry.utils.filterObject(this.token.toObject(), formData);
    } catch(err) {
      Hooks.onError("DefaultTokenConfig#_updateObject", err, {notify: "error"});
    }

    // Diff the form data against normal defaults
    const defaults = foundry.documents.BaseToken.cleanData();
    const delta = foundry.utils.diffObject(defaults, formData);
    await game.settings.set("core", DefaultTokenConfig.SETTING, delta);
    return SettingsConfig.reloadConfirm({ world: true });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('button[data-action="reset"]').click(this.reset.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Reset the form to default values
   * @returns {Promise<void>}
   */
  async reset() {
    const cls = getDocumentClass("Token");
    this.object = new cls({}, {strict: false});
    this.token = this.object;
    this.render();
  }

  /* --------------------------------------------- */

  /** @inheritdoc */
  async _onBarChange() {}

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onAddDetectionMode(modes) {
    super._onAddDetectionMode(modes);
    this.document.updateSource({ detectionModes: modes });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onRemoveDetectionMode(index, modes) {
    super._onRemoveDetectionMode(index, modes);
    this.document.updateSource({ detectionModes: modes });
  }
}

/**
 * An implementation of the PlaceableHUD base class which renders a heads-up-display interface for Token objects.
 * This interface provides controls for visibility, attribute bars, elevation, status effects, and more.
 * The TokenHUD implementation can be configured and replaced via {@link CONFIG.Token.hudClass}.
 * @extends {BasePlaceableHUD<Token, TokenDocument, TokenLayer>}
 */
class TokenHUD extends BasePlaceableHUD {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "token-hud",
      template: "templates/hud/token-hud.html"
    });
  }

  /* -------------------------------------------- */

  /**
   * Track whether the status effects control palette is currently expanded or hidden
   * @type {boolean}
   */
  #statusTrayActive = false;

  /* -------------------------------------------- */

  /**
   * Convenience reference to the Actor modified by this TokenHUD.
   * @type {Actor}
   */
  get actor() {
    return this.document?.actor;
  }

  /* -------------------------------------------- */

  /** @override */
  bind(object) {
    this.#statusTrayActive = false;
    return super.bind(object);
  }

  /* -------------------------------------------- */

  /** @override */
  setPosition(_position) {
    const b = this.object.bounds;
    const {width, height} = this.document;
    const ratio = canvas.dimensions.size / 100;
    const position = {width: width * 100, height: height * 100, left: b.left, top: b.top};
    if ( ratio !== 1 ) position.transform = `scale(${ratio})`;
    this.element.css(position);
    this.element[0].classList.toggle("large", height >= 2);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData(options={}) {
    let data = super.getData(options);
    const bar1 = this.document.getBarAttribute("bar1");
    const bar2 = this.document.getBarAttribute("bar2");
    data = foundry.utils.mergeObject(data, {
      canConfigure: game.user.can("TOKEN_CONFIGURE"),
      canToggleCombat: ui.combat !== null,
      displayBar1: bar1 && (bar1.type !== "none"),
      bar1Data: bar1,
      displayBar2: bar2 && (bar2.type !== "none"),
      bar2Data: bar2,
      visibilityClass: data.hidden ? "active" : "",
      effectsClass: this.#statusTrayActive ? "active" : "",
      combatClass: this.object.inCombat ? "active" : "",
      targetClass: this.object.targeted.has(game.user) ? "active" : ""
    });
    data.statusEffects = this._getStatusEffectChoices();
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Get an array of icon paths which represent valid status effect choices.
   * @protected
   */
  _getStatusEffectChoices() {

    // Include all HUD-enabled status effects
    const choices = {};
    for ( const status of CONFIG.statusEffects ) {
      if ( (status.hud === false) || ((foundry.utils.getType(status.hud) === "Object")
        && (status.hud.actorTypes?.includes(this.document.actor.type) === false)) ) {
        continue;
      }
      choices[status.id] = {
        _id: status._id,
        id: status.id,
        title: game.i18n.localize(status.name ?? /** @deprecated since v12 */ status.label),
        src: status.img ?? /** @deprecated since v12 */ status.icon,
        isActive: false,
        isOverlay: false
      };
    }

    // Update the status of effects which are active for the token actor
    const activeEffects = this.actor?.effects || [];
    for ( const effect of activeEffects ) {
      for ( const statusId of effect.statuses ) {
        const status = choices[statusId];
        if ( !status ) continue;
        if ( status._id ) {
          if ( status._id !== effect.id ) continue;
        } else {
          if ( effect.statuses.size !== 1 ) continue;
        }
        status.isActive = true;
        if ( effect.getFlag("core", "overlay") ) status.isOverlay = true;
        break;
      }
    }

    // Flag status CSS class
    for ( const status of Object.values(choices) ) {
      status.cssClass = [
        status.isActive ? "active" : null,
        status.isOverlay ? "overlay" : null
      ].filterJoin(" ");
    }
    return choices;
  }

  /* -------------------------------------------- */

  /**
   * Toggle the expanded state of the status effects selection tray.
   * @param {boolean} [active]        Force the status tray to be active or inactive
   */
  toggleStatusTray(active) {
    active ??= !this.#statusTrayActive;
    this.#statusTrayActive = active;
    const button = this.element.find('.control-icon[data-action="effects"]')[0];
    button.classList.toggle("active", active);
    const palette = this.element[0].querySelector(".status-effects");
    palette.classList.toggle("active", active);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    this.toggleStatusTray(this.#statusTrayActive);
    const effectsTray = html.find(".status-effects");
    effectsTray.on("click", ".effect-control", this.#onToggleEffect.bind(this));
    effectsTray.on("contextmenu", ".effect-control", event => this.#onToggleEffect(event, {overlay: true}));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onClickControl(event) {
    super._onClickControl(event);
    if ( event.defaultPrevented ) return;
    const button = event.currentTarget;
    switch ( button.dataset.action ) {
      case "config":
        return this.#onTokenConfig(event);
      case "combat":
        return this.#onToggleCombat(event);
      case "target":
        return this.#onToggleTarget(event);
      case "effects":
        return this.#onToggleStatusEffects(event);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateAttribute(name, input) {
    const attr = this.document.getBarAttribute(name);
    if ( !attr ) return super._updateAttribute(name, input);
    const {value, delta, isDelta, isBar} = this._parseAttributeInput(name, attr, input);
    await this.actor?.modifyTokenAttribute(attr.attribute, isDelta ? delta : value, isDelta, isBar);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the combat state of all controlled Tokens.
   * @param {PointerEvent} event
   */
  async #onToggleCombat(event) {
    event.preventDefault();
    const tokens = canvas.tokens.controlled.map(t => t.document);
    if ( !this.object.controlled ) tokens.push(this.document);
    try {
      if ( this.document.inCombat ) await TokenDocument.implementation.deleteCombatants(tokens);
      else await TokenDocument.implementation.createCombatants(tokens);
    } catch(err) {
      ui.notifications.warn(err.message);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle Token configuration button click.
   * @param {PointerEvent} event
   */
  #onTokenConfig(event) {
    event.preventDefault();
    this.object.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle left-click events to toggle the displayed state of the status effect selection palette
   * @param {PointerEvent} event
   */
  #onToggleStatusEffects(event) {
    event.preventDefault();
    this.toggleStatusTray(!this.#statusTrayActive);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a token status effect icon
   * @param {PointerEvent} event      The click event to toggle the effect
   * @param {object} [options]        Options which modify the toggle
   * @param {boolean} [options.overlay]   Toggle the overlay effect?
   */
  #onToggleEffect(event, {overlay=false}={}) {
    event.preventDefault();
    event.stopPropagation();
    if ( !this.actor ) return ui.notifications.warn("HUD.WarningEffectNoActor", {localize: true});
    const statusId = event.currentTarget.dataset.statusId;
    this.actor.toggleStatusEffect(statusId, {overlay});
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the target state for this Token
   * @param {PointerEvent} event      The click event to toggle the target
   */
  #onToggleTarget(event) {
    event.preventDefault();
    const btn = event.currentTarget;
    const token = this.object;
    const targeted = !token.isTargeted;
    token.setTarget(targeted, {releaseOthers: false});
    btn.classList.toggle("active", targeted);
  }
}

/**
 * The Application responsible for configuring a single Wall document within a parent Scene.
 * @param {Wall} object                       The Wall object for which settings are being configured
 * @param {FormApplicationOptions} [options]  Additional options which configure the rendering of the configuration
 *                                            sheet.
 */
class WallConfig extends DocumentSheet {

  /** @inheritdoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.classes.push("wall-config");
    options.template = "templates/scene/wall-config.html";
    options.width = 400;
    options.height = "auto";
    return options;
  }

  /**
   * An array of Wall ids that should all be edited when changes to this config form are submitted
   * @type {string[]}
   */
  editTargets = [];

  /* -------------------------------------------- */

  /** @inheritdoc */
  get title() {
    if ( this.editTargets.length > 1 ) return game.i18n.localize("WALLS.TitleMany");
    return super.title;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  render(force, options) {
    if ( options?.walls instanceof Array ) {
      this.editTargets = options.walls.map(w => w.id);
    }
    return super.render(force, options);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    const context = super.getData(options);
    context.source = this.document.toObject();
    context.p0 = {x: this.object.c[0], y: this.object.c[1]};
    context.p1 = {x: this.object.c[2], y: this.object.c[3]};
    context.gridUnits = this.document.parent.grid.units || game.i18n.localize("GridUnits");
    context.moveTypes = Object.keys(CONST.WALL_MOVEMENT_TYPES).reduce((obj, key) => {
      let k = CONST.WALL_MOVEMENT_TYPES[key];
      obj[k] = game.i18n.localize(`WALLS.SenseTypes.${key}`);
      return obj;
    }, {});
    context.senseTypes = Object.keys(CONST.WALL_SENSE_TYPES).reduce((obj, key) => {
      let k = CONST.WALL_SENSE_TYPES[key];
      obj[k] = game.i18n.localize(`WALLS.SenseTypes.${key}`);
      return obj;
    }, {});
    context.dirTypes = Object.keys(CONST.WALL_DIRECTIONS).reduce((obj, key) => {
      let k = CONST.WALL_DIRECTIONS[key];
      obj[k] = game.i18n.localize(`WALLS.Directions.${key}`);
      return obj;
    }, {});
    context.doorTypes = Object.keys(CONST.WALL_DOOR_TYPES).reduce((obj, key) => {
      let k = CONST.WALL_DOOR_TYPES[key];
      obj[k] = game.i18n.localize(`WALLS.DoorTypes.${key}`);
      return obj;
    }, {});
    context.doorStates = Object.keys(CONST.WALL_DOOR_STATES).reduce((obj, key) => {
      let k = CONST.WALL_DOOR_STATES[key];
      obj[k] = game.i18n.localize(`WALLS.DoorStates.${key}`);
      return obj;
    }, {});
    context.doorSounds = CONFIG.Wall.doorSounds;
    context.isDoor = this.object.isDoor;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    html.find(".audio-preview").click(this.#onAudioPreview.bind(this));
    this.#enableDoorOptions(this.document.door > CONST.WALL_DOOR_TYPES.NONE);
    this.#toggleThresholdInputVisibility();
    return super.activateListeners(html);
  }

  /* -------------------------------------------- */

  #audioPreviewState = 0;

  /**
   * Handle previewing a sound file for a Wall setting
   * @param {Event} event   The initial button click event
   */
  #onAudioPreview(event) {
    const doorSoundName = this.form.doorSound.value;
    const doorSound = CONFIG.Wall.doorSounds[doorSoundName];
    if ( !doorSound ) return;
    const interactions = CONST.WALL_DOOR_INTERACTIONS;
    const interaction = interactions[this.#audioPreviewState++ % interactions.length];
    let sounds = doorSound[interaction];
    if ( !sounds ) return;
    if ( !Array.isArray(sounds) ) sounds = [sounds];
    const src = sounds[Math.floor(Math.random() * sounds.length)];
    game.audio.play(src, {context: game.audio.interface});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onChangeInput(event) {
    if ( event.currentTarget.name === "door" ) {
      this.#enableDoorOptions(Number(event.currentTarget.value) > CONST.WALL_DOOR_TYPES.NONE);
    }
    else if ( event.currentTarget.name === "doorSound" ) {
      this.#audioPreviewState = 0;
    }
    else if ( ["light", "sight", "sound"].includes(event.currentTarget.name) ) {
      this.#toggleThresholdInputVisibility();
    }
    return super._onChangeInput(event);
  }

  /* -------------------------------------------- */

  /**
   * Toggle the disabled attribute of the door state select.
   * @param {boolean} isDoor
   */
  #enableDoorOptions(isDoor) {
    const doorOptions = this.form.querySelector(".door-options");
    doorOptions.disabled = !isDoor;
    doorOptions.classList.toggle("hidden", !isDoor);
    this.setPosition({height: "auto"});
  }

  /* -------------------------------------------- */

  /**
   * Toggle visibility of proximity input fields.
   */
  #toggleThresholdInputVisibility() {
    const form = this.form;
    const showTypes = [CONST.WALL_SENSE_TYPES.PROXIMITY, CONST.WALL_SENSE_TYPES.DISTANCE];
    for ( const sense of ["light", "sight", "sound"] ) {
      const select = form[sense];
      const input = select.parentElement.querySelector(".proximity");
      input.classList.toggle("hidden", !showTypes.includes(Number(select.value)));
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getSubmitData(updateData={}) {
    const thresholdTypes = [CONST.WALL_SENSE_TYPES.PROXIMITY, CONST.WALL_SENSE_TYPES.DISTANCE];
    const formData = super._getSubmitData(updateData);
    for ( const sense of ["light", "sight", "sound"] ) {
      if ( !thresholdTypes.includes(formData[sense]) ) formData[`threshold.${sense}`] = null;
    }
    return formData;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {

    // Update multiple walls
    if ( this.editTargets.length > 1 ) {
      const updateData = canvas.scene.walls.reduce((arr, w) => {
        if ( this.editTargets.includes(w.id) ) {
          arr.push(foundry.utils.mergeObject(w.toJSON(), formData));
        }
        return arr;
      }, []);
      return canvas.scene.updateEmbeddedDocuments("Wall", updateData, {sound: false});
    }

    // Update single wall
    if ( !this.object.id ) return;
    return this.object.update(formData, {sound: false});
  }
}

/**
 * A simple application which supports popping a ChatMessage out to a separate UI window.
 * @extends {Application}
 * @param {ChatMessage} object            The {@link ChatMessage} object that is being popped out.
 * @param {ApplicationOptions} [options]  Application configuration options.
 */
class ChatPopout extends Application {
  constructor(message, options) {
    super(options);

    /**
     * The displayed Chat Message document
     * @type {ChatMessage}
     */
    this.message = message;

    // Register the application
    this.message.apps[this.appId] = this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      width: 300,
      height: "auto",
      classes: ["chat-popout"]
    });
  }

  /* -------------------------------------------- */

  /** @override */
  get id() {
    return `chat-popout-${this.message.id}`;
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    let title = this.message.flavor ?? this.message.speaker.alias;
    return TextEditor.previewHTML(title, 32);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderInner(_data) {
    const html = await this.message.getHTML();
    html.find(".message-delete").remove();
    return html;
  }
}

/**
 * The Application responsible for displaying and editing the client and world settings for this world.
 * This form renders the settings defined via the game.settings.register API which have config = true
 */
class SettingsConfig extends PackageConfiguration {

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      title: game.i18n.localize("SETTINGS.Title"),
      id: "client-settings",
      categoryTemplate: "templates/sidebar/apps/settings-config-category.html",
      submitButton: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareCategoryData() {
    const gs = game.settings;
    const canConfigure = game.user.can("SETTINGS_MODIFY");
    let categories = new Map();
    let total = 0;

    const getCategory = category => {
      let cat = categories.get(category.id);
      if ( !cat ) {
        cat = {
          id: category.id,
          title: category.title,
          menus: [],
          settings: [],
          count: 0
        };
        categories.set(category.id, cat);
      }
      return cat;
    };

    // Classify all menus
    for ( let menu of gs.menus.values() ) {
      if ( menu.restricted && !canConfigure ) continue;
      if ( (menu.key === "core.permissions") && !game.user.hasRole("GAMEMASTER") ) continue;
      const category = getCategory(this._categorizeEntry(menu.namespace));
      category.menus.push(menu);
      total++;
    }

    // Classify all settings
    for ( let setting of gs.settings.values() ) {
      if ( !setting.config || (!canConfigure && (setting.scope !== "client")) ) continue;

      // Update setting data
      const s = foundry.utils.deepClone(setting);
      s.id = `${s.namespace}.${s.key}`;
      s.name = game.i18n.localize(s.name);
      s.hint = game.i18n.localize(s.hint);
      s.value = game.settings.get(s.namespace, s.key);
      s.type = setting.type instanceof Function ? setting.type.name : "String";
      s.isCheckbox = setting.type === Boolean;
      s.isSelect = s.choices !== undefined;
      s.isRange = (setting.type === Number) && s.range;
      s.isNumber = setting.type === Number;
      s.filePickerType = s.filePicker === true ? "any" : s.filePicker;
      s.dataField = setting.type instanceof foundry.data.fields.DataField ? setting.type : null;
      s.input = setting.input;

      // Categorize setting
      const category = getCategory(this._categorizeEntry(setting.namespace));
      category.settings.push(s);
      total++;
    }

    // Sort categories by priority and assign Counts
    for ( let category of categories.values() ) {
      category.count = category.menus.length + category.settings.length;
    }
    categories = Array.from(categories.values()).sort(this._sortCategories.bind(this));
    return {categories, total, user: game.user, canConfigure};
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".submenu button").click(this._onClickSubmenu.bind(this));
    html.find('[name="core.fontSize"]').change(this._previewFontScaling.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle activating the button to configure User Role permissions
   * @param {Event} event   The initial button click event
   * @private
   */
  _onClickSubmenu(event) {
    event.preventDefault();
    const menu = game.settings.menus.get(event.currentTarget.dataset.key);
    if ( !menu ) return ui.notifications.error("No submenu found for the provided key");
    const app = new menu.type();
    return app.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Preview font scaling as the setting is changed.
   * @param {Event} event  The triggering event.
   * @private
   */
  _previewFontScaling(event) {
    const scale = Number(event.currentTarget.value);
    game.scaleFonts(scale);
    this.setPosition();
  }

  /* --------------------------------------------- */

  /** @inheritdoc */
  async close(options={}) {
    game.scaleFonts();
    return super.close(options);
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    let requiresClientReload = false;
    let requiresWorldReload = false;
    for ( let [k, v] of Object.entries(foundry.utils.flattenObject(formData)) ) {
      let s = game.settings.settings.get(k);
      let current = game.settings.get(s.namespace, s.key);
      if ( v === current ) continue;
      requiresClientReload ||= (s.scope === "client") && s.requiresReload;
      requiresWorldReload ||= (s.scope === "world") && s.requiresReload;
      await game.settings.set(s.namespace, s.key, v);
    }
    if ( requiresClientReload || requiresWorldReload ) this.constructor.reloadConfirm({world: requiresWorldReload});
  }

  /* -------------------------------------------- */

  /**
   * Handle button click to reset default settings
   * @param {Event} event   The initial button click event
   * @private
   */
  _onResetDefaults(event) {
    event.preventDefault();
    const form = this.element.find("form")[0];
    for ( let [k, v] of game.settings.settings.entries() ) {
      if ( !v.config ) continue;
      const input = form[k];
      if ( !input ) continue;
      if ( input.type === "checkbox" ) input.checked = v.default;
      else input.value = v.default;
      $(input).change();
    }
    ui.notifications.info("SETTINGS.ResetInfo", {localize: true});
  }

  /* -------------------------------------------- */

  /**
   * Confirm if the user wishes to reload the application.
   * @param {object} [options]               Additional options to configure the prompt.
   * @param {boolean} [options.world=false]  Whether to reload all connected clients as well.
   * @returns {Promise<void>}
   */
  static async reloadConfirm({world=false}={}) {
    const reload = await foundry.applications.api.DialogV2.confirm({
      id: "reload-world-confirm",
      modal: true,
      rejectClose: false,
      window: { title: "SETTINGS.ReloadPromptTitle" },
      position: { width: 400 },
      content: `<p>${game.i18n.localize("SETTINGS.ReloadPromptBody")}</p>`
    });
    if ( !reload ) return;
    if ( world && game.user.can("SETTINGS_MODIFY") ) game.socket.emit("reload");
    foundry.utils.debouncedReload();
  }
}

/**
 * An interface for displaying the content of a CompendiumCollection.
 * @param {CompendiumCollection} collection  The {@link CompendiumCollection} object represented by this interface.
 * @param {ApplicationOptions} [options]     Application configuration options.
 */
class Compendium extends DocumentDirectory {
  constructor(...args) {
    if ( args[0] instanceof Collection ) {
      foundry.utils.logCompatibilityWarning("Compendium constructor should now be passed a CompendiumCollection "
        + "instance via {collection: compendiumCollection}", {
        since: 11,
        until: 13
      });
      args[1] ||= {};
      args[1].collection = args.shift();
    }
    super(...args);
  }

  /* -------------------------------------------- */

  /** @override */
  get entryType() {
    return this.metadata.type;
  }

  /* -------------------------------------------- */

  /** @override */
  static entryPartial = "templates/sidebar/partials/compendium-index-partial.html";

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "templates/apps/compendium.html",
      width: 350,
      height: window.innerHeight - 100,
      top: 70,
      left: 120,
      popOut: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get id() {
    return `compendium-${this.collection.collection}`;
  }

  /* ----------------------------------------- */

  /** @inheritdoc */
  get title() {
    const title = game.i18n.localize(this.collection.title);
    return this.collection.locked ? `${title} [${game.i18n.localize("PACKAGE.Locked")}]` : title;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get tabName() {
    return "Compendium";
  }

  /* -------------------------------------------- */

  /** @override */
  get canCreateEntry() {
    const cls = getDocumentClass(this.collection.documentName);
    const isOwner = this.collection.testUserPermission(game.user, "OWNER");
    return !this.collection.locked && isOwner && cls.canUserCreate(game.user);
  }

  /* -------------------------------------------- */

  /** @override */
  get canCreateFolder() {
    return this.canCreateEntry;
  }

  /* ----------------------------------------- */

  /**
   * A convenience redirection back to the metadata object of the associated CompendiumCollection
   * @returns {object}
   */
  get metadata() {
    return this.collection.metadata;
  }

  /* -------------------------------------------- */

  /** @override */
  initialize() {
    this.collection.initializeTree();
  }

  /* ----------------------------------------- */
  /*  Rendering                                */
  /* ----------------------------------------- */

  /** @inheritDoc */
  render(force, options) {
    if ( !this.collection.visible ) {
      if ( force ) ui.notifications.warn("COMPENDIUM.CannotViewWarning", {localize: true});
      return this;
    }
    return super.render(force, options);
  }

  /* ----------------------------------------- */

  /** @inheritdoc */
  async getData(options={}) {
    const context = await super.getData(options);
    return foundry.utils.mergeObject(context, {
      collection: this.collection,
      index: this.collection.index,
      name: game.i18n.localize(this.metadata.label),
      footerButtons: []
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  _entryAlreadyExists(document) {
    return (document.pack === this.collection.collection) && this.collection.index.has(document.id);
  }

  /* -------------------------------------------- */

  /** @override */
  async _createDroppedEntry(document, folderId) {
    document = document.clone({folder: folderId || null}, {keepId: true});
    return this.collection.importDocument(document);
  }

  /* -------------------------------------------- */

  /** @override */
  _getEntryDragData(entryId) {
    return {
      type: this.collection.documentName,
      uuid: this.collection.getUuid(entryId)
    };
  }

  /* -------------------------------------------- */

  /** @override */
  _onCreateEntry(event) {
    // If this is an Adventure, use the Adventure Exporter application
    if ( this.collection.documentName === "Adventure" ) {
      const adventure = new Adventure({name: "New Adventure"}, {pack: this.collection.collection});
      return new CONFIG.Adventure.exporterClass(adventure).render(true);
    }
    return super._onCreateEntry(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _getFolderDragData(folderId) {
    const folder = this.collection.folders.get(folderId);
    if ( !folder ) return null;
    return {
      type: "Folder",
      uuid: folder.uuid
    };
  }

  /* -------------------------------------------- */

  /** @override */
  _getFolderContextOptions() {
    const toRemove = ["OWNERSHIP.Configure", "FOLDER.Export"];
    return super._getFolderContextOptions().filter(o => !toRemove.includes(o.name));
  }

  /* -------------------------------------------- */

  /** @override */
  _getEntryContextOptions() {
    const isAdventure = this.collection.documentName === "Adventure";
    return [
      {
        name: "COMPENDIUM.ImportEntry",
        icon: '<i class="fas fa-download"></i>',
        condition: () => !isAdventure && this.collection.documentClass.canUserCreate(game.user),
        callback: li => {
          const collection = game.collections.get(this.collection.documentName);
          const id = li.data("document-id");
          return collection.importFromCompendium(this.collection, id, {}, {renderSheet: true});
        }
      },
      {
        name: "ADVENTURE.ExportEdit",
        icon: '<i class="fa-solid fa-edit"></i>',
        condition: () => isAdventure && game.user.isGM && !this.collection.locked,
        callback: async li => {
          const id = li.data("document-id");
          const document = await this.collection.getDocument(id);
          return new CONFIG.Adventure.exporterClass(document.clone({}, {keepId: true})).render(true);
        }
      },
      {
        name: "SCENES.GenerateThumb",
        icon: '<i class="fas fa-image"></i>',
        condition: () => !this.collection.locked && (this.collection.documentName === "Scene"),
        callback: async li => {
          const scene = await this.collection.getDocument(li.data("document-id"));
          scene.createThumbnail().then(data => {
            scene.update({thumb: data.thumb}, {diff: false});
            ui.notifications.info(game.i18n.format("SCENES.GenerateThumbSuccess", {name: scene.name}));
          }).catch(err => ui.notifications.error(err.message));
        }
      },
      {
        name: "COMPENDIUM.DeleteEntry",
        icon: '<i class="fas fa-trash"></i>',
        condition: () => game.user.isGM && !this.collection.locked,
        callback: async li => {
          const id = li.data("document-id");
          const document = await this.collection.getDocument(id);
          return document.deleteDialog();
        }
      }
    ];
  }
}

/**
 * A class responsible for prompting the user about dependency resolution for their modules.
 */
class DependencyResolution extends FormApplication {
  /**
   * @typedef {object} DependencyResolutionInfo
   * @property {Module} module       The module.
   * @property {boolean} checked     Has the user toggled the checked state of this dependency in this application.
   * @property {string} [reason]     Some reason associated with the dependency.
   * @property {boolean} [required]  Whether this module is a hard requirement and cannot be unchecked.
   */

  /**
   * @typedef {FormApplicationOptions} DependencyResolutionAppOptions
   * @property {boolean} enabling  Whether the root dependency is being enabled or disabled.
   */

  /**
   * @param {ModuleManagement} manager  The module management application.
   * @param {Module} root               The module that is the root of the dependency resolution.
   * @param {DependencyResolutionAppOptions} [options]  Additional options that configure resolution behavior.
   */
  constructor(manager, root, options={}) {
    super(root, options);
    this.#manager = manager;

    // Always include the root module.
    this.#modules.set(root.id, root);

    // Determine initial state.
    if ( options.enabling ) this.#initializeEnabling();
    else this.#initializeDisabling();
  }

  /**
   * The full set of modules considered for dependency resolution stemming from the root module.
   * @type {Set<Module>}
   */
  #candidates = new Set();

  /**
   * The set of all modules dependent on a given module.
   * @type {Map<Module, Set<Module>>}
   */
  #dependents = new Map();

  /**
   * The module management application.
   * @type {ModuleManagement}
   */
  #manager;

  /**
   * A subset of the games modules that are currently active in the module manager.
   * @type {Map<string, Module>}
   */
  #modules = new Map();

  /**
   * Track the changes being made by the user as part of dependency resolution.
   * @type {Map<Module, DependencyResolutionInfo>}
   */
  #resolution = new Map();

  /* -------------------------------------------- */

  /**
   * Whether there are additional dependencies that need resolving by the user.
   * @type {boolean}
   */
  get needsResolving() {
    if ( this.options.enabling ) return this.#candidates.size > 0;
    return (this.#candidates.size > 1) || !!this.#getUnavailableSubtypes();
  }

  /* -------------------------------------------- */

  /**
   * @inheritdoc
   * @returns {DependencyResolutionAppOptions}
   */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      enabling: true,
      template: "templates/setup/impacted-dependencies.html"
    });
  }

  /* -------------------------------------------- */

  /** @override */
  getData(options={}) {
    const required = [];
    const optional = [];
    let subtypes;

    if ( this.options.enabling ) {
      const context = this.#getDependencyContext();
      required.push(...context.required);
      optional.push(...context.optional);
    } else {
      optional.push(...this.#getUnusedContext());
      subtypes = this.#getUnavailableSubtypes();
    }

    return {
      required, optional, subtypes,
      enabling: this.options.enabling
    };
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('input[type="checkbox"]').on("change", this._onChangeCheckbox.bind(this));
    html.find("[data-action]").on("click", this._onAction.bind(this));
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _render(force, options) {
    await super._render(force, options);
    this.setPosition({ height: "auto" });
  }

  /* -------------------------------------------- */

  /**
   * Handle the user toggling a dependency.
   * @param {Event} event  The checkbox change event.
   * @protected
   */
  _onChangeCheckbox(event) {
    const target = event.currentTarget;
    const module = this.#modules.get(target.name);
    const checked = target.checked;
    const resolution = this.#resolution.get(module);
    resolution.checked = checked;
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle button presses.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onAction(event) {
    const action = event.currentTarget.dataset.action;
    switch ( action ) {
      case "cancel":
        this.close();
        break;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _getSubmitData(updateData={}) {
    const fd = new FormDataExtended(this.form, { disabled: true });
    return foundry.utils.mergeObject(fd.object, updateData);
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    formData[this.object.id] = true;
    this.#manager._onSelectDependencies(formData, this.options.enabling);
  }

  /* -------------------------------------------- */

  /**
   * Return any modules that the root module is required by.
   * @returns {Set<Module>}
   * @internal
   */
  _getRootRequiredBy() {
    const requiredBy = new Set();
    if ( this.options.enabling ) return requiredBy;
    const dependents = this.#dependents.get(this.object);
    for ( const dependent of (dependents ?? []) ) {
      if ( dependent.relationships.requires.find(({ id }) => id === this.object.id) ) {
        requiredBy.add(dependent);
      }
    }
    return requiredBy;
  }

  /* -------------------------------------------- */

  /**
   * Build the structure of modules that are dependent on other modules.
   */
  #buildDependents() {
    const addDependent = (module, dep) => {
      dep = this.#modules.get(dep.id);
      if ( !dep ) return;
      if ( !this.#dependents.has(dep) ) this.#dependents.set(dep, new Set());
      const dependents = this.#dependents.get(dep);
      dependents.add(module);
    };

    for ( const module of this.#modules.values() ) {
      for ( const dep of module.relationships.requires ) addDependent(module, dep);
      for ( const dep of module.relationships.recommends ) addDependent(module, dep);
    }
  }

  /* -------------------------------------------- */

  /**
   * Recurse down the dependency tree and gather modules that are required or optional.
   * @param {Set<Module>} [skip]  If any of these modules are encountered in the graph, skip them.
   * @returns {Map<Module, DependencyResolutionInfo>}
   */
  #getDependencies(skip=new Set()) {
    const resolution = new Map();

    const addDependency = (module, { required=false, reason, dependent }={}) => {
      if ( !resolution.has(module) ) resolution.set(module, { module, checked: true });
      const info = resolution.get(module);
      if ( !info.required ) info.required = required;
      if ( reason ) {
        if ( info.reason ) info.reason += "<br>";
        info.reason += `${dependent.title}: ${reason}`;
      }
    };

    const addDependencies = (module, deps, required=false) => {
      for ( const { id, reason } of deps ) {
        const dep = this.#modules.get(id);
        if ( !dep ) continue;
        const info = resolution.get(dep);

        // Avoid cycles in the dependency graph.
        if ( info && (info.required === true || info.required === required) ) continue;

        // Add every dependency we see so tha user can toggle them on and off, but do not traverse the graph any further
        // if we have indicated this dependency should be skipped.
        addDependency(dep, { reason, required, dependent: module });
        if ( skip.has(dep) ) continue;

        addDependencies(dep, dep.relationships.requires, true);
        addDependencies(dep, dep.relationships.recommends);
      }
    };

    addDependencies(this.object, this.object.relationships.requires, true);
    addDependencies(this.object, this.object.relationships.recommends);
    return resolution;
  }

  /* -------------------------------------------- */

  /**
   * Get the set of all modules that would be unused (i.e. have no dependents) if the given set of modules were
   * disabled.
   * @param {Set<Module>} disabling  The set of modules that are candidates for disablement.
   * @returns {Set<Module>}
   */
  #getUnused(disabling) {
    const unused = new Set();
    for ( const module of this.#modules.values() ) {
      const dependents = this.#dependents.get(module);
      if ( !dependents ) continue;

      // What dependents are left if we remove the set of to-be-disabled modules?
      const remaining = dependents.difference(disabling);
      if ( !remaining.size ) unused.add(module);
    }
    return unused;
  }

  /* -------------------------------------------- */

  /**
   * Find the maximum dependents that can be pruned if the root module is disabled.
   * Starting at the root module, add all modules that would become unused to the set of modules to disable. For each
   * module added in this way, check again for new modules that would become unused. Repeat until there are no more
   * unused modules.
   */
  #initializeDisabling() {
    const disabling = new Set([this.object]);

    // Initialize modules.
    for ( const module of game.modules ) {
      if ( this.#manager._isModuleChecked(module.id) ) this.#modules.set(module.id, module);
    }

    // Initialize dependents.
    this.#buildDependents();

    // Set a maximum iteration limit of 100 to prevent accidental infinite recursion.
    for ( let i = 0; i < 100; i++ ) {
      const unused = this.#getUnused(disabling);
      if ( !unused.size ) break;
      unused.forEach(disabling.add, disabling);
    }

    this.#candidates = disabling;

    // Initialize resolution state.
    for ( const module of disabling ) {
      this.#resolution.set(module, { module, checked: true, required: false });
    }
  }

  /* -------------------------------------------- */

  /**
   * Find the full list of recursive dependencies for the root module.
   */
  #initializeEnabling() {
    // Initialize modules.
    for ( const module of game.modules ) {
      if ( !this.#manager._isModuleChecked(module.id) ) this.#modules.set(module.id, module);
    }

    // Traverse the dependency graph and locate dependencies that need activation.
    this.#resolution = this.#getDependencies();
    for ( const module of this.#resolution.keys() ) this.#candidates.add(module);
  }

  /* -------------------------------------------- */

  /**
   * The list of modules that the user currently has selected, including the root module.
   * @returns {Set<Module>}
   */
  #getSelectedModules() {
    const selected = new Set([this.object]);
    for ( const module of this.#candidates ) {
      const { checked } = this.#resolution.get(module);
      if ( checked ) selected.add(module);
    }
    return selected;
  }

  /* -------------------------------------------- */

  /**
   * After the user has adjusted their choices, re-calculate the dependency graph.
   * Display all modules which are still in the set of reachable dependencies, preserving their checked states. If a
   * module is no longer reachable in the dependency graph (because there are no more checked modules that list it as
   * a dependency), do not display it to the user.
   * @returns {{required: DependencyResolutionInfo[], optional: DependencyResolutionInfo[]}}
   */
  #getDependencyContext() {
    const skip = Array.from(this.#resolution.values()).reduce((acc, info) => {
      if ( info.checked === false ) acc.add(info.module);
      return acc;
    }, new Set());

    const dependencies = this.#getDependencies(skip);
    const required = [];
    const optional = [];

    for ( const module of this.#candidates ) {
      if ( !dependencies.has(module) ) continue;
      const info = this.#resolution.get(module);
      if ( info.required ) required.push(info);
      else optional.push(info);
    }

    return { required, optional };
  }

  /* -------------------------------------------- */

  /**
   * After the user has adjusted their choices, re-calculate which modules are still unused.
   * Display all modules which are still unused, preserving their checked states. If a module is no longer unused
   * (because a module that uses it was recently unchecked), do not display it to the user.
   * @returns {DependencyResolutionInfo[]}
   */
  #getUnusedContext() {
    // Re-calculate unused modules after we remove those the user unchecked.
    const unused = this.#getUnused(this.#getSelectedModules());
    const context = [];
    for ( const module of this.#candidates ) {
      if ( unused.has(module) ) context.push(this.#resolution.get(module));
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Get a formatted string of the Documents that would be rendered unavailable if the currently-selected modules were
   * to be disabled.
   * @returns {string}
   */
  #getUnavailableSubtypes() {
    const allCounts = {};
    for ( const module of this.#getSelectedModules() ) {
      const counts = game.issues.getSubTypeCountsFor(module);
      if ( !counts ) continue;
      Object.entries(counts).forEach(([documentName, subtypes]) => {
        const documentCounts = allCounts[documentName] ??= {};
        Object.entries(subtypes).forEach(([subtype, count]) => {
          documentCounts[subtype] = (documentCounts[subtype] ?? 0) + count;
        });
      });
    }
    return this.#manager._formatDocumentSummary(allCounts, true);
  }
}

/**
 * Game Invitation Links Reference
 * @extends {Application}
 */
class InvitationLinks extends Application {
  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "invitation-links",
      template: "templates/sidebar/apps/invitation-links.html",
      title: game.i18n.localize("INVITATIONS.Title"),
      width: 400
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async getData(options={}) {
    let addresses = game.data.addresses;
    // Check for IPv6 detection, and don't display connectivity info if so
    if ( addresses.remote === undefined ) return addresses;

    // Otherwise, handle remote connection test
    if ( addresses.remoteIsAccessible == null ) {
      addresses.remoteClass = "unknown-connection";
      addresses.remoteTitle = game.i18n.localize("INVITATIONS.UnknownConnection");
      addresses.failedCheck = true;
    } else if ( addresses.remoteIsAccessible ) {
      addresses.remoteClass = "connection";
      addresses.remoteTitle = game.i18n.localize("INVITATIONS.OpenConnection");
      addresses.canConnect = true;
    } else {
      addresses.remoteClass = "no-connection";
      addresses.remoteTitle = game.i18n.localize("INVITATIONS.ClosedConnection");
      addresses.canConnect = false;
    }
    return addresses;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".invite-link").click(ev => {
      ev.preventDefault();
      ev.target.select();
      game.clipboard.copyPlainText(ev.currentTarget.value);
      ui.notifications.info("INVITATIONS.Copied", {localize: true});
    });
    html.find(".refresh").click(ev => {
      ev.preventDefault();
      const icon = ev.currentTarget;
      icon.className = "fas fa-sync fa-pulse";
      let me = this;
      setTimeout(function(){
        game.socket.emit("refreshAddresses",  addresses => {
          game.data.addresses = addresses;
          me.render(true);
        });
      }, 250)
    });
    html.find(".show-hide").click(ev => {
      ev.preventDefault();
      const icon = ev.currentTarget;
      const showLink = icon.classList.contains("show-link");
      if ( showLink ) {
        icon.classList.replace("fa-eye", "fa-eye-slash");
        icon.classList.replace("show-link", "hide-link");
      }
      else {
        icon.classList.replace("fa-eye-slash", "fa-eye");
        icon.classList.replace("hide-link", "show-link");
      }
      icon.closest("form").querySelector('#remote-link').type = showLink ? "text" : "password";
    });
  }
}

/**
 * Allows for viewing and editing of Keybinding Actions
 */
class KeybindingsConfig extends PackageConfiguration {

  /**
   * Categories present in the app. Within each category is an array of package data
   * @type {{categories: object[], total: number}}
   * @protected
   */
  #cachedData;

  /**
   * A Map of pending Edits. The Keys are bindingIds
   * @type {Map<string, KeybindingActionBinding[]>}
   * @private
   */
  #pendingEdits = new Map();

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      title: game.i18n.localize("SETTINGS.Keybindings"),
      id: "keybindings",
      categoryTemplate: "templates/sidebar/apps/keybindings-config-category.html",
      scrollY: [".scrollable"]
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get categoryOrder() {
    const categories = super.categoryOrder;
    categories.splice(2, 0, "core-mouse");
    return categories;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _categorizeEntry(namespace) {
    const category = super._categorizeEntry(namespace);
    if ( namespace === "core" ) category.title = game.i18n.localize("KEYBINDINGS.CoreKeybindings");
    return category;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareCategoryData() {
    if ( this.#cachedData ) return this.#cachedData;

    // Classify all Actions
    let categories = new Map();
    let totalActions = 0;
    const ctrlString = KeyboardManager.CONTROL_KEY_STRING;
    for ( let [actionId, action] of game.keybindings.actions ) {
      if ( action.restricted && !game.user.isGM ) continue;
      totalActions++;

      // Determine what category the action belongs to
      let category = this._categorizeEntry(action.namespace);

      // Carry over bindings for future rendering
      const actionData = foundry.utils.deepClone(action);
      actionData.category = category.title;
      actionData.id = actionId;
      actionData.name = game.i18n.localize(action.name);
      actionData.hint = game.i18n.localize(action.hint);
      actionData.cssClass = action.restricted ? "gm" : "";
      actionData.notes = [
        action.restricted ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
        action.reservedModifiers.length > 0 ? game.i18n.format("KEYBINDINGS.ReservedModifiers", {
          modifiers: action.reservedModifiers.map(m => m === "Control" ? ctrlString : m.titleCase()).join(", ")
        }) : "",
        game.i18n.localize(action.hint)
      ].filterJoin("<br>");
      actionData.uneditable = action.uneditable;

      // Prepare binding-level data
      actionData.bindings = (game.keybindings.bindings.get(actionId) ?? []).map((b, i) => {
        const uneditable = action.uneditable.includes(b);
        const binding = foundry.utils.deepClone(b);
        binding.id = `${actionId}.binding.${i}`;
        binding.display = KeybindingsConfig._humanizeBinding(binding);
        binding.cssClasses = uneditable ? "uneditable" : "";
        binding.isEditable = !uneditable;
        binding.isFirst = i === 0;
        const conflicts = this._detectConflictingActions(actionId, action, binding);
        binding.conflicts = game.i18n.format("KEYBINDINGS.Conflict", {
          conflicts: conflicts.map(action => game.i18n.localize(action.name)).join(", ")
        });
        binding.hasConflicts = conflicts.length > 0;
        return binding;
      });
      actionData.noBindings = actionData.bindings.length === 0;

      // Register a category the first time it is seen, otherwise add to it
      if ( !categories.has(category.id) ) {
        categories.set(category.id, {
          id: category.id,
          title: category.title,
          actions: [actionData],
          count: 0
        });

      } else categories.get(category.id).actions.push(actionData);
    }

    // Add Mouse Controls
    totalActions += this._addMouseControlsReference(categories);

    // Sort Actions by priority and assign Counts
    for ( let category of categories.values() ) {
      category.actions = category.actions.sort(ClientKeybindings._compareActions);
      category.count = category.actions.length;
    }
    categories = Array.from(categories.values()).sort(this._sortCategories.bind(this));
    return this.#cachedData = {categories, total: totalActions};
  }

  /* -------------------------------------------- */

  /**
   * Add faux-keybind actions that represent the possible Mouse Controls
   * @param {Map} categories    The current Map of Categories to add to
   * @returns {number}           The number of Actions added
   * @private
   */
  _addMouseControlsReference(categories) {
    let coreMouseCategory = game.i18n.localize("KEYBINDINGS.CoreMouse");

    const defineMouseAction = (id, name, keys, gmOnly=false) => {
      return {
        category: coreMouseCategory,
        id: id,
        name: game.i18n.localize(name),
        notes: gmOnly ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
        bindings: [
          {
            display: keys.map(k => game.i18n.localize(k)).join(" + "),
            cssClasses: "uneditable",
            isEditable: false,
            hasConflicts: false,
            isFirst: false
          }
        ]
      };
    };

    const actions = [
      ["canvas-select", "CONTROLS.CanvasSelect", ["CONTROLS.LeftClick"]],
      ["canvas-select-many", "CONTROLS.CanvasSelectMany", ["Shift", "CONTROLS.LeftClick"]],
      ["canvas-drag", "CONTROLS.CanvasLeftDrag", ["CONTROLS.LeftClick", "CONTROLS.Drag"]],
      ["canvas-select-cancel", "CONTROLS.CanvasSelectCancel", ["CONTROLS.RightClick"]],
      ["canvas-pan-mouse", "CONTROLS.CanvasPan", ["CONTROLS.RightClick", "CONTROLS.Drag"]],
      ["canvas-zoom", "CONTROLS.CanvasSelectCancel", ["CONTROLS.MouseWheel"]],
      ["ruler-measure", "CONTROLS.RulerMeasure", [KeyboardManager.CONTROL_KEY_STRING, "CONTROLS.LeftDrag"]],
      ["ruler-measure-waypoint", "CONTROLS.RulerWaypoint", [KeyboardManager.CONTROL_KEY_STRING, "CONTROLS.LeftClick"]],
      ["object-sheet", "CONTROLS.ObjectSheet", [`${game.i18n.localize("CONTROLS.Double")} ${game.i18n.localize("CONTROLS.LeftClick")}`]],
      ["object-hud", "CONTROLS.ObjectHUD", ["CONTROLS.RightClick"]],
      ["object-config", "CONTROLS.ObjectConfig", [`${game.i18n.localize("CONTROLS.Double")} ${game.i18n.localize("CONTROLS.RightClick")}`]],
      ["object-drag", "CONTROLS.ObjectDrag", ["CONTROLS.LeftClick", "CONTROLS.Drag"]],
      ["object-no-snap", "CONTROLS.ObjectNoSnap", ["CONTROLS.Drag", "Shift", "CONTROLS.Drop"]],
      ["object-drag-cancel", "CONTROLS.ObjectDragCancel", [`${game.i18n.localize("CONTROLS.RightClick")} ${game.i18n.localize("CONTROLS.During")} ${game.i18n.localize("CONTROLS.Drag")}`]],
      ["object-rotate-slow", "CONTROLS.ObjectRotateSlow", [KeyboardManager.CONTROL_KEY_STRING, "CONTROLS.MouseWheel"]],
      ["object-rotate-fast", "CONTROLS.ObjectRotateFast", ["Shift", "CONTROLS.MouseWheel"]],
      ["place-hidden-token", "CONTROLS.TokenPlaceHidden", ["Alt", "CONTROLS.Drop"], true],
      ["token-target-mouse", "CONTROLS.TokenTarget", [`${game.i18n.localize("CONTROLS.Double")} ${game.i18n.localize("CONTROLS.RightClick")}`]],
      ["canvas-ping", "CONTROLS.CanvasPing", ["CONTROLS.LongPress"]],
      ["canvas-ping-alert", "CONTROLS.CanvasPingAlert", ["Alt", "CONTROLS.LongPress"]],
      ["canvas-ping-pull", "CONTROLS.CanvasPingPull", ["Shift", "CONTROLS.LongPress"], true],
      ["tooltip-lock", "CONTROLS.TooltipLock", ["CONTROLS.MiddleClick"]],
      ["tooltip-dismiss", "CONTROLS.TooltipDismiss", ["CONTROLS.RightClick"]]
    ];

    let coreMouseCategoryData = {
      id: "core-mouse",
      title: coreMouseCategory,
      actions: actions.map(a => defineMouseAction(...a)),
      count: 0
    };
    coreMouseCategoryData.count = coreMouseCategoryData.actions.length;
    categories.set("core-mouse", coreMouseCategoryData);
    return coreMouseCategoryData.count;
  }

  /* -------------------------------------------- */

  /**
   * Given an Binding and its parent Action, detects other Actions that might conflict with that binding
   * @param {string} actionId                   The namespaced Action ID the Binding belongs to
   * @param {KeybindingActionConfig} action     The Action config
   * @param {KeybindingActionBinding} binding   The Binding
   * @returns {KeybindingAction[]}
   * @private
   */
  _detectConflictingActions(actionId, action, binding) {

    // Uneditable Core bindings are never wrong, they can never conflict with something
    if ( actionId.startsWith("core.") && action.uneditable.includes(binding) ) return [];

    // Build fake context
    /** @type KeyboardEventContext */
    const context = KeyboardManager.getKeyboardEventContext({
      code: binding.key,
      shiftKey: binding.modifiers.includes(KeyboardManager.MODIFIER_KEYS.SHIFT),
      ctrlKey: binding.modifiers.includes(KeyboardManager.MODIFIER_KEYS.CONTROL),
      altKey: binding.modifiers.includes(KeyboardManager.MODIFIER_KEYS.ALT),
      repeat: false
    });

    // Return matching keybinding actions (excluding this one)
    let matching = KeyboardManager._getMatchingActions(context);
    return matching.filter(a => a.action !== actionId);
  }

  /* -------------------------------------------- */

  /**
   * Transforms a Binding into a human-readable string representation
   * @param {KeybindingActionBinding} binding   The Binding
   * @returns {string}                           A human readable string
   * @private
   */
  static _humanizeBinding(binding) {
    const stringParts = binding.modifiers.reduce((parts, part) => {
      if ( KeyboardManager.MODIFIER_CODES[part]?.includes(binding.key) ) return parts;
      parts.unshift(KeyboardManager.getKeycodeDisplayString(part));
      return parts;
    }, [KeyboardManager.getKeycodeDisplayString(binding.key)]);
    return stringParts.join(" + ");
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    const actionBindings = html.find(".action-bindings");
    actionBindings.on("dblclick", ".editable-binding", this._onDoubleClickKey.bind(this));
    actionBindings.on("click", ".control", this._onClickBindingControl.bind(this));
    actionBindings.on("keydown", ".binding-input", this._onKeydownBindingInput.bind(this));
  }

  /* -------------------------------------------- */

  /** @override */
  async _onResetDefaults(event) {
    return Dialog.confirm({
      title: game.i18n.localize("KEYBINDINGS.ResetTitle"),
      content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("KEYBINDINGS.ResetWarning")}</p>`,
      yes: async () => {
        await game.keybindings.resetDefaults();
        this.#cachedData = undefined;
        this.#pendingEdits.clear();
        this.render();
        ui.notifications.info("KEYBINDINGS.ResetSuccess", {localize: true});
      },
      no: () => {},
      defaultYes: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle Control clicks
   * @param {MouseEvent} event
   * @private
   */
  _onClickBindingControl(event) {
    const button = event.currentTarget;
    switch ( button.dataset.action ) {
      case "add":
        this._onClickAdd(event); break;
      case "delete":
        this._onClickDelete(event); break;
      case "edit":
        return this._onClickEditableBinding(event);
      case "save":
        return this._onClickSaveBinding(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle left-click events to show / hide a certain category
   * @param {MouseEvent} event
   * @private
   */
  async _onClickAdd(event) {
    const {actionId, namespace, action} = this._getParentAction(event);
    const {bindingHtml, bindingId} = this._getParentBinding(event);
    const bindings = game.keybindings.bindings.get(actionId);
    const newBindingId = `${namespace}.${action}.binding.${bindings.length}`;
    const toInsert =
      `<li class="binding flexrow inserted" data-binding-id="${newBindingId}">
          <div class="editable-binding">
              <div class="form-fields binding-fields">
                  <input type="text" class="binding-input" name="${newBindingId}" id="${newBindingId}" placeholder="Control + 1">
                  <i class="far fa-keyboard binding-input-icon"></i>
              </div>
          </div>
          <div class="binding-controls flexrow">
            <a class="control save-edit" title="${game.i18n.localize("KEYBINDINGS.SaveBinding")}" data-action="save"><i class="fas fa-save"></i></a>
            <a class="control" title="${game.i18n.localize("KEYBINDINGS.DeleteBinding")}" data-action="delete"><i class="fas fa-trash-alt"></i></a>
          </div>
      </li>`;
    bindingHtml.closest(".action-bindings").insertAdjacentHTML("beforeend", toInsert);
    document.getElementById(newBindingId).focus();

    // If this is an empty binding, delete it
    if ( bindingId === "empty" ) {
      bindingHtml.remove();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle left-click events to show / hide a certain category
   * @param {MouseEvent} event
   * @private
   */
  async _onClickDelete(event) {
    const {namespace, action} = this._getParentAction(event);
    const {bindingId} = this._getParentBinding(event);
    const bindingIndex = Number.parseInt(bindingId.split(".")[3]);
    this._addPendingEdit(namespace, action, bindingIndex, {index: bindingIndex, key: null});
    await this._savePendingEdits();
  }

  /* -------------------------------------------- */

  /**
   * Inserts a Binding into the Pending Edits object, creating a new Map entry as needed
   * @param {string} namespace
   * @param {string} action
   * @param {number} bindingIndex
   * @param {KeybindingActionBinding} binding
   * @private
   */
  _addPendingEdit(namespace, action, bindingIndex, binding) {
    // Save pending edits
    const pendingEditKey = `${namespace}.${action}`;
    if ( this.#pendingEdits.has(pendingEditKey) ) {
      // Filter out any existing pending edits for this Binding so we don't add each Key in "Shift + A"
      let currentBindings = this.#pendingEdits.get(pendingEditKey).filter(x => x.index !== bindingIndex);
      currentBindings.push(binding);
      this.#pendingEdits.set(pendingEditKey, currentBindings);
    } else {
      this.#pendingEdits.set(pendingEditKey, [binding]);
    }
  }

  /* -------------------------------------------- */

  /**
   * Toggle visibility of the Edit / Save UI
   * @param {MouseEvent} event
   * @private
   */
  _onClickEditableBinding(event) {
    const target = event.currentTarget;
    const bindingRow = target.closest("li.binding");
    target.classList.toggle("hidden");
    bindingRow.querySelector(".save-edit").classList.toggle("hidden");
    for ( let binding of bindingRow.querySelectorAll(".editable-binding") ) {
      binding.classList.toggle("hidden");
      binding.getElementsByClassName("binding-input")[0]?.focus();
    }
  }

  /* -------------------------------------------- */

  /**
   * Toggle visibility of the Edit UI
   * @param {MouseEvent} event
   * @private
   */
  _onDoubleClickKey(event) {
    const target = event.currentTarget;

    // If this is an inserted binding, don't try to swap to a non-edit mode
    if ( target.parentNode.parentNode.classList.contains("inserted") ) return;
    for ( let child of target.parentNode.getElementsByClassName("editable-binding") ) {
      child.classList.toggle("hidden");
      child.getElementsByClassName("binding-input")[0]?.focus();
    }
    const bindingRow = target.closest(".binding");
    for ( let child of bindingRow.getElementsByClassName("save-edit") ) {
      child.classList.toggle("hidden");
    }
  }

  /* -------------------------------------------- */

  /**
   * Save the new Binding value and update the display of the UI
   * @param {MouseEvent} event
   * @private
   */
  async _onClickSaveBinding(event) {
    await this._savePendingEdits();
  }

  /* -------------------------------------------- */

  /**
   * Given a clicked Action element, finds the parent Action
   * @param {MouseEvent|KeyboardEvent} event
   * @returns {{namespace: string, action: string, actionHtml: *}}
   * @private
   */
  _getParentAction(event) {
    const actionHtml = event.currentTarget.closest(".action");
    const actionId = actionHtml.dataset.actionId;
    let [namespace, ...action] = actionId.split(".");
    action = action.join(".");
    return {actionId, actionHtml, namespace, action};
  }

  /* -------------------------------------------- */

  /**
   * Given a Clicked binding control element, finds the parent Binding
   * @param {MouseEvent|KeyboardEvent} event
   * @returns {{bindingHtml: *, bindingId: string}}
   * @private
   */
  _getParentBinding(event) {
    const bindingHtml = event.currentTarget.closest(".binding");
    const bindingId = bindingHtml.dataset.bindingId;
    return {bindingHtml, bindingId};
  }

  /* -------------------------------------------- */

  /**
   * Iterates over all Pending edits, merging them in with unedited Bindings and then saving and resetting the UI
   * @returns {Promise<void>}
   * @private
   */
  async _savePendingEdits() {
    for ( let [id, pendingBindings] of this.#pendingEdits ) {
      let [namespace, ...action] = id.split(".");
      action = action.join(".");
      const bindingsData = game.keybindings.bindings.get(id);
      const actionData = game.keybindings.actions.get(id);

      // Identify the set of bindings which should be saved
      const toSet = [];
      for ( const [index, binding] of bindingsData.entries() ) {
        if ( actionData.uneditable.includes(binding) ) continue;
        const {key, modifiers} = binding;
        toSet[index] = {key, modifiers};
      }
      for ( const binding of pendingBindings ) {
        const {index, key, modifiers} = binding;
        toSet[index] = {key, modifiers};
      }

      // Try to save the binding, reporting any errors
      try {
        await game.keybindings.set(namespace, action, toSet.filter(b => !!b?.key));
      }
      catch(e) {
        ui.notifications.error(e);
      }
    }

    // Reset and rerender
    this.#cachedData = undefined;
    this.#pendingEdits.clear();
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Processes input from the keyboard to form a list of pending Binding edits
   * @param {KeyboardEvent} event   The keyboard event
   * @private
   */
  _onKeydownBindingInput(event) {
    const context = KeyboardManager.getKeyboardEventContext(event);

    // Stop propagation
    event.preventDefault();
    event.stopPropagation();

    const {bindingHtml, bindingId} = this._getParentBinding(event);
    const {namespace, action} = this._getParentAction(event);

    // Build pending Binding
    const bindingIdParts = bindingId.split(".");
    const bindingIndex = Number.parseInt(bindingIdParts[bindingIdParts.length - 1]);
    const {MODIFIER_KEYS, MODIFIER_CODES} = KeyboardManager;
    /** @typedef {KeybindingActionBinding} **/
    let binding = {
      index: bindingIndex,
      key: context.key,
      modifiers: []
    };
    if ( context.isAlt && !MODIFIER_CODES[MODIFIER_KEYS.ALT].includes(context.key) ) {
      binding.modifiers.push(MODIFIER_KEYS.ALT);
    }
    if ( context.isShift && !MODIFIER_CODES[MODIFIER_KEYS.SHIFT].includes(context.key) ) {
      binding.modifiers.push(MODIFIER_KEYS.SHIFT);
    }
    if ( context.isControl && !MODIFIER_CODES[MODIFIER_KEYS.CONTROL].includes(context.key) ) {
      binding.modifiers.push(MODIFIER_KEYS.CONTROL);
    }

    // Save pending edits
    this._addPendingEdit(namespace, action, bindingIndex, binding);

    // Predetect potential conflicts
    const conflicts = this._detectConflictingActions(`${namespace}.${action}`, game.keybindings.actions.get(`${namespace}.${action}`), binding);
    const conflictString = game.i18n.format("KEYBINDINGS.Conflict", {
      conflicts: conflicts.map(action => game.i18n.localize(action.name)).join(", ")
    });

    // Remove existing conflicts and add a new one
    for ( const conflict of bindingHtml.getElementsByClassName("conflicts") ) {
      conflict.remove();
    }
    if ( conflicts.length > 0 ) {
      const conflictHtml = `<div class="control conflicts" title="${conflictString}"><i class="fas fa-exclamation-triangle"></i></div>`;
      bindingHtml.getElementsByClassName("binding-controls")[0].insertAdjacentHTML("afterbegin", conflictHtml);
    }

    // Set value
    event.currentTarget.value = this.constructor._humanizeBinding(binding);
  }
}

/**
 * The Module Management Application.
 * This application provides a view of which modules are available to be used and allows for configuration of the
 * set of modules which are active within the World.
 */
class ModuleManagement extends FormApplication {
  constructor(...args) {
    super(...args);
    this._filter = this.isEditable ? "all" : "active";
    this._expanded = true;
  }

  /**
   * The named game setting which persists module configuration.
   * @type {string}
   */
  static CONFIG_SETTING = "moduleConfiguration";

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      title: game.i18n.localize("MODMANAGE.Title"),
      id: "module-management",
      template: "templates/sidebar/apps/module-management.html",
      popOut: true,
      width: 680,
      height: "auto",
      scrollY: [".package-list"],
      closeOnSubmit: false,
      filters: [{inputSelector: 'input[name="search"]', contentSelector: ".package-list"}]
    });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  get isEditable() {
    return game.user.can("SETTINGS_MODIFY");
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  getData(options={}) {
    const editable = this.isEditable;
    const counts = {all: game.modules.size, active: 0, inactive: 0};

    // Prepare modules
    const modules = game.modules.reduce((arr, module) => {
      const isActive = module.active;
      if ( isActive ) counts.active++;
      else if ( !editable ) return arr;
      else counts.inactive++;

      const mod = module.toObject();
      mod.active = isActive;
      mod.css = isActive ? " active" : "";
      mod.hasPacks = mod.packs.length > 0;
      mod.hasScripts = mod.scripts.length > 0;
      mod.hasStyles = mod.styles.length > 0;
      mod.systemOnly = mod.relationships?.systems.find(s => s.id === game.system.id);
      mod.systemTag = game.system.id;
      mod.authors = mod.authors.map(a => {
        if ( a.url ) return `<a href="${a.url}" target="_blank">${a.name}</a>`;
        return a.name;
      }).join(", ");
      mod.tooltip = null; // No tooltip by default
      const requiredModules = Array.from(game.world.relationships.requires)
        .concat(Array.from(game.system.relationships.requires));
      mod.required = !!requiredModules.find(r => r.id === mod.id);
      if ( mod.required ) mod.tooltip = game.i18n.localize("MODMANAGE.RequiredModule");

      // String formatting labels
      const authorsLabel = game.i18n.localize(`Author${module.authors.size > 1 ? "Pl" : ""}`);
      mod.labels = {authors: authorsLabel};
      mod.badge = module.getVersionBadge();

      // Document counts.
      const subTypeCounts = game.issues.getSubTypeCountsFor(mod);
      if ( subTypeCounts ) mod.documents = this._formatDocumentSummary(subTypeCounts, isActive);

      // If the current System is not one of the supported ones, don't return
      if ( mod.relationships?.systems.size > 0 && !mod.systemOnly ) return arr;

      mod.enableable = true;
      this._evaluateDependencies(mod);
      this._evaluateSystemCompatibility(mod);
      mod.disabled = mod.required || !mod.enableable;
      return arr.concat([mod]);
    }, []).sort((a, b) => a.title.localeCompare(b.title, game.i18n.lang));

    // Filters
    const filters = editable ? ["all", "active", "inactive"].map(f => ({
      id: f,
      label: game.i18n.localize(`MODMANAGE.Filter${f.titleCase()}`),
      count: counts[f] || 0
    })) : [];

    // Return data for rendering
    return { editable, filters, modules, expanded: this._expanded };
  }

  /* -------------------------------------------- */

  /**
   * Given a module, determines if it meets minimum and maximum compatibility requirements of its dependencies.
   * If not, it is marked as being unable to be activated.
   * If the package does not meet verified requirements, it is marked with a warning instead.
   * @param {object} module  The module.
   * @protected
   */
  _evaluateDependencies(module) {
    for ( const required of module.relationships.requires ) {
      if ( required.type !== "module" ) continue;

      // Verify the required package is installed
      const pkg = game.modules.get(required.id);
      if ( !pkg ) {
        module.enableable = false;
        required.class = "error";
        required.message = game.i18n.localize("SETUP.DependencyNotInstalled");
        continue;
      }

      // Test required package compatibility
      const c = required.compatibility;
      if ( !c ) continue;
      const dependencyVersion = pkg.version;
      if ( c.minimum && foundry.utils.isNewerVersion(c.minimum, dependencyVersion) ) {
        module.enableable = false;
        required.class = "error";
        required.message = game.i18n.format("SETUP.CompatibilityRequireUpdate",
          { version: required.compatibility.minimum});
        continue;
      }
      if ( c.maximum && foundry.utils.isNewerVersion(dependencyVersion, c.maximum) ) {
        module.enableable = false;
        required.class = "error";
        required.message = game.i18n.format("SETUP.CompatibilityRequireDowngrade",
          { version: required.compatibility.maximum});
        continue;
      }
      if ( c.verified && !foundry.utils.isNewerVersion(dependencyVersion, c.verified) ) {
        required.class = "warning";
        required.message = game.i18n.format("SETUP.CompatibilityRiskWithVersion",
          {version: required.compatibility.verified});
      }
    }

    // Record that a module may not be able to be enabled
    if ( !module.enableable ) module.tooltip = game.i18n.localize("MODMANAGE.DependencyIssues");
  }

  /* -------------------------------------------- */

  /**
   * Given a module, determine if it meets the minimum and maximum system compatibility requirements.
   * @param {object} module  The module.
   * @protected
   */
  _evaluateSystemCompatibility(module) {
    if ( !module.relationships.systems?.length ) return;
    const supportedSystem = module.relationships.systems.find(s => s.id === game.system.id);
    const {minimum, maximum} = supportedSystem?.compatibility ?? {};
    const {version} = game.system;
    if ( !minimum && !maximum ) return;
    if ( minimum && foundry.utils.isNewerVersion(minimum, version) ) {
      module.enableable = false;
      module.tooltip = game.i18n.format("MODMANAGE.SystemCompatibilityIssueMinimum", {minimum, version});
    }
    if ( maximum && foundry.utils.isNewerVersion(version, maximum) ) {
      module.enableable = false;
      module.tooltip = game.i18n.format("MODMANAGE.SystemCompatibilityIssueMaximum", {maximum, version});
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('button[name="deactivate"]').click(this._onDeactivateAll.bind(this));
    html.find(".filter").click(this._onFilterList.bind(this));
    html.find("button.expand").click(this._onExpandCollapse.bind(this));
    html.find('input[type="checkbox"]').change(this._onChangeCheckbox.bind(this));

    // Allow users to filter modules even if they don't have permission to edit them.
    html.find('input[name="search"]').attr("disabled", false);
    html.find("button.expand").attr("disabled", false);

    // Activate the appropriate filter.
    html.find(`a[data-filter="${this._filter}"]`).addClass("active");

    // Initialize
    this._onExpandCollapse();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _renderInner(...args) {
    await loadTemplates(["templates/setup/parts/package-tags.hbs"]);
    return super._renderInner(...args);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getSubmitData(updateData={}) {
    const formData = super._getSubmitData(updateData);
    delete formData.search;
    return formData;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {
    const settings = game.settings.get("core", this.constructor.CONFIG_SETTING);
    const requiresReload = !foundry.utils.isEmpty(foundry.utils.diffObject(settings, formData));
    const setting = foundry.utils.mergeObject(settings, formData);
    const listFormatter = game.i18n.getListFormatter();

    // Ensure all relationships are satisfied
    for ( let [k, v] of Object.entries(setting) ) {
      if ( v === false ) continue;
      const mod = game.modules.get(k);
      if ( !mod ) {
        delete setting[k];
        continue;
      }
      if ( !mod.relationships?.requires?.size ) continue;
      const missing = mod.relationships.requires.reduce((arr, d) => {
        if ( d.type && (d.type !== "module") ) return arr;
        if ( !setting[d.id] ) arr.push(d.id);
        return arr;
      }, []);
      if ( missing.length ) {
        const warning = game.i18n.format("MODMANAGE.DepMissing", {module: k, missing: listFormatter.format(missing)});
        this.options.closeOnSubmit = false;
        return ui.notifications.warn(warning);
      }
    }

    // Apply the setting
    if ( requiresReload ) SettingsConfig.reloadConfirm({world: true});
    return game.settings.set("core", this.constructor.CONFIG_SETTING, setting);
  }

  /* -------------------------------------------- */

  /**
   * Update the checked state of modules based on user dependency resolution.
   * @param {Record<string, boolean>} formData  The dependency resolution result.
   * @param {boolean} enabling                  Whether the user was performing an enabling or disabling workflow.
   * @internal
   */
  _onSelectDependencies(formData, enabling) {
    for ( const [id, checked] of Object.entries(formData) ) {
      this.form.elements[id].checked = enabling ? checked : !checked;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to a module checkbox to prompt for whether to enable dependencies.
   * @param {Event} event  The change event.
   * @protected
   */
  async _onChangeCheckbox(event) {
    const input = event.target;
    const module = game.modules.get(input.name);
    const enabling = input.checked;
    const resolver = new DependencyResolution(this, module, { enabling });
    const requiredBy = resolver._getRootRequiredBy();

    if ( requiredBy.size || resolver.needsResolving ) {
      this.form.elements[input.name].checked = !enabling;
      if ( requiredBy.size ) {
        // TODO: Rather than throwing an error, we should prompt the user to disable all dependent modules, as well as
        // all their dependents, recursively, and all unused modules that would result from those disablings.
        const listFormatter = game.i18n.getListFormatter();
        const dependents = listFormatter.format(Array.from(requiredBy).map(m => m.title));
        ui.notifications.error(game.i18n.format("MODMANAGE.RequiredDepError", { dependents }), { console: false });
      }
      else resolver.render(true);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle a button-click to deactivate all modules
   * @private
   */
  _onDeactivateAll(event) {
    event.preventDefault();
    for ( let input of this.element[0].querySelectorAll('input[type="checkbox"]') ) {
      if ( !input.disabled ) input.checked = false;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle expanding or collapsing the display of descriptive elements
   * @private
   */
  _onExpandCollapse(event) {
    event?.preventDefault();
    this._expanded = !this._expanded;
    this.form.querySelectorAll(".package-description").forEach(pack =>
      pack.classList.toggle("hidden", !this._expanded)
    );
    const icon = this.form.querySelector("i.fa");
    icon.classList.toggle("fa-angle-double-down", this._expanded);
    icon.classList.toggle("fa-angle-double-up", !this._expanded);
    icon.parentElement.title = this._expanded ?
      game.i18n.localize("Collapse") : game.i18n.localize("Expand");
  }

  /* -------------------------------------------- */

  /**
   * Handle switching the module list filter.
   * @private
   */
  _onFilterList(event) {
    event.preventDefault();
    this._filter = event.target.dataset.filter;

    // Toggle the activity state of all filters.
    this.form.querySelectorAll("a[data-filter]").forEach(a =>
      a.classList.toggle("active", a.dataset.filter === this._filter));

    // Iterate over modules and toggle their hidden states based on the chosen filter.
    const settings = game.settings.get("core", this.constructor.CONFIG_SETTING);
    const list = this.form.querySelector("#module-list");
    for ( const li of list.children ) {
      const name = li.dataset.moduleId;
      const isActive = settings[name] === true;
      const hidden = ((this._filter === "active") && !isActive) || ((this._filter === "inactive") && isActive);
      li.classList.toggle("hidden", hidden);
    }

    // Re-apply any search filter query.
    const searchFilter = this._searchFilters[0];
    searchFilter.filter(null, searchFilter._input.value);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onSearchFilter(event, query, rgx, html) {
    const settings = game.settings.get("core", this.constructor.CONFIG_SETTING);
    for ( let li of html.children ) {
      const name = li.dataset.moduleId;
      const isActive = settings[name] === true;
      if ( (this._filter === "active") && !isActive ) continue;
      if ( (this._filter === "inactive") && isActive ) continue;
      if ( !query ) {
        li.classList.remove("hidden");
        continue;
      }
      const title = (li.querySelector(".package-title")?.textContent || "").trim();
      const author = (li.querySelector(".author")?.textContent || "").trim();
      const match = rgx.test(SearchFilter.cleanQuery(name)) ||
        rgx.test(SearchFilter.cleanQuery(title)) ||
        rgx.test(SearchFilter.cleanQuery(author));
      li.classList.toggle("hidden", !match);
    }
  }

  /* -------------------------------------------- */

  /**
   * Format a document count collection for display.
   * @param {ModuleSubTypeCounts} counts  An object of sub-type counts.
   * @param {boolean} isActive            Whether the module is active.
   * @internal
   */
  _formatDocumentSummary(counts, isActive) {
    return Object.entries(counts).map(([documentName, types]) => {
      let total = 0;
      const typesList = game.i18n.getListFormatter().format(Object.entries(types).map(([subType, count]) => {
        total += count;
        const label = game.i18n.localize(CONFIG[documentName].typeLabels?.[subType] ?? subType);
        return `<strong>${count}</strong> ${label}`;
      }));
      const cls = getDocumentClass(documentName);
      const label = total === 1 ? cls.metadata.label : cls.metadata.labelPlural;
      if ( isActive ) return `${typesList} ${game.i18n.localize(label)}`;
      return `<strong>${total}</strong> ${game.i18n.localize(label)}`;
    }).join(" &bull; ");
  }

  /* -------------------------------------------- */

  /**
   * Check if a module is enabled currently in the application.
   * @param {string} id  The module ID.
   * @returns {boolean}
   * @internal
   */
  _isModuleChecked(id) {
    return !!this.form.elements[id]?.checked;
  }
}

/**
 * Support Info and Report
 * @type {Application}
 */
class SupportDetails extends Application {
  /** @inheritdoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.title = "SUPPORT.Title";
    options.id = "support-details";
    options.template = "templates/sidebar/apps/support-details.html";
    options.width = 780;
    options.height = 680;
    options.resizable = true;
    options.classes = ["sheet"];
    options.tabs = [{navSelector: ".tabs", contentSelector: "article", initial: "support"}];
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async getData(options={}) {
    const context = super.getData(options);

    // Build report data
    context.report = await SupportDetails.generateSupportReport();

    // Build document issues data.
    context.documentIssues = this._getDocumentValidationErrors();

    // Build module issues data.
    context.moduleIssues = this._getModuleIssues();

    // Build client issues data.
    context.clientIssues = Object.values(game.issues.usabilityIssues).map(({message, severity, params}) => {
      return {severity, message: params ? game.i18n.format(message, params) : game.i18n.localize(message)};
    });

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("button[data-action]").on("click", this._onClickAction.bind(this));
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _render(force=false, options={}) {
    await super._render(force, options);
    if ( options.tab ) this._tabs[0].activate(options.tab);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _renderInner(data) {
    await loadTemplates({supportDetailsReport: "templates/sidebar/apps/parts/support-details-report.html"});
    return super._renderInner(data);
  }

  /* -------------------------------------------- */

  /**
   * Handle a button click action.
   * @param {MouseEvent} event  The click event.
   * @protected
   */
  _onClickAction(event) {
    const action = event.currentTarget.dataset.action;
    switch ( action ) {
      case "copy":
        this._copyReport();
        break;

      case "fullReport":
        this.#generateFullReport();
        break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Generate a more detailed support report and append it to the basic report.
   */
  async #generateFullReport() {
    let fullReport = "";
    const report = document.getElementById("support-report");
    const [button] = this.element.find('[data-action="fullReport"]');
    const icon = button.querySelector("i");
    button.disabled = true;
    icon.className = "fas fa-spinner fa-spin-pulse";

    const sizeInfo = await this.#getWorldSizeInfo();
    const { worldSizes, packSizes } = Object.entries(sizeInfo).reduce((obj, entry) => {
      const [collectionName] = entry;
      if ( collectionName.includes(".") ) obj.packSizes.push(entry);
      else obj.worldSizes.push(entry);
      return obj;
    }, { worldSizes: [], packSizes: [] });

    fullReport += `\n${this.#drawBox(game.i18n.localize("SUPPORT.WorldData"))}\n\n`;
    fullReport += worldSizes.map(([collectionName, size]) => {
      let collection = game[collectionName];
      if ( collectionName === "fog" ) collection = game.collections.get("FogExploration");
      else if ( collectionName === "settings" ) collection = game.collections.get("Setting");
      return `${collection.name}: ${collection.size} | ${foundry.utils.formatFileSize(size, { decimalPlaces: 0 })}`;
    }).join("\n");

    if ( packSizes.length ) {
      fullReport += `\n\n${this.#drawBox(game.i18n.localize("SUPPORT.CompendiumData"))}\n\n`;
      fullReport += packSizes.map(([collectionName, size]) => {
        const pack = game.packs.get(collectionName);
        const type = game.i18n.localize(pack.documentClass.metadata.labelPlural);
        size = foundry.utils.formatFileSize(size, { decimalPlaces: 0 });
        return `"${collectionName}": ${pack.index.size} ${type} | ${size}`;
      }).join("\n");
    }

    const activeModules = game.modules.filter(m => m.active);
    if ( activeModules.length ) {
      fullReport += `\n\n${this.#drawBox(game.i18n.localize("SUPPORT.ActiveModules"))}\n\n`;
      fullReport += activeModules.map(m => `${m.id} | ${m.version} | "${m.title}" | "${m.manifest}"`).join("\n");
    }

    icon.className = "fas fa-check";
    report.innerText += fullReport;
    this.setPosition({ height: "auto" });
  }

  /* -------------------------------------------- */

  /**
   * Retrieve information about the size of the World and any active compendiums.
   * @returns {Promise<Record<string, number>>}
   */
  async #getWorldSizeInfo() {
    return new Promise(resolve => game.socket.emit("sizeInfo", resolve));
  }

  /* -------------------------------------------- */

  /**
   * Draw an ASCII box around the given string for legibility in the full report.
   * @param {string} text  The text.
   * @returns {string}
   */
  #drawBox(text) {
    const border = `/* ${"-".repeat(44)} */`;
    return `${border}\n/*  ${text}${" ".repeat(border.length - text.length - 6)}*/\n${border}`;
  }

  /* -------------------------------------------- */

  /**
   * Copy the support details report to clipboard.
   * @protected
   */
  _copyReport() {
    const report = document.getElementById("support-report");
    game.clipboard.copyPlainText(report.innerText);
    ui.notifications.info("SUPPORT.ReportCopied", {localize: true});
  }

  /* -------------------------------------------- */

  /**
   * Marshal information on Documents that failed validation and format it for display.
   * @returns {object[]}
   * @protected
   */
  _getDocumentValidationErrors() {
    const context = [];
    for ( const [documentName, documents] of Object.entries(game.issues.validationFailures) ) {
      const cls = getDocumentClass(documentName);
      const label = game.i18n.localize(cls.metadata.labelPlural);
      context.push({
        label,
        documents: Object.entries(documents).map(([id, {name, error}]) => {
          return {name: name ?? id, validationError: error.asHTML()};
        })
      });
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Marshal package-related warnings and errors and format it for display.
   * @returns {object[]}
   * @protected
   */
  _getModuleIssues() {
    const errors = {label: game.i18n.localize("Errors"), issues: []};
    const warnings = {label: game.i18n.localize("Warnings"), issues: []};
    for ( const [moduleId, {error, warning}] of Object.entries(game.issues.packageCompatibilityIssues) ) {
      const label = game.modules.get(moduleId)?.title ?? moduleId;
      if ( error.length ) errors.issues.push({label, issues: error.map(message => ({severity: "error", message}))});
      if ( warning.length ) warnings.issues.push({
        label,
        issues: warning.map(message => ({severity: "warning", message}))
      });
    }
    const context = [];
    if ( errors.issues.length ) context.push(errors);
    if ( warnings.issues.length ) context.push(warnings);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * A bundle of metrics for Support
   * @typedef {Object} SupportReportData
   * @property {string} coreVersion
   * @property {string} systemVersion
   * @property {number} activeModuleCount
   * @property {string} os
   * @property {string} client
   * @property {string} gpu
   * @property {number|string} maxTextureSize
   * @property {string} sceneDimensions
   * @property {number} grid
   * @property {number} padding
   * @property {number} walls
   * @property {number} lights
   * @property {number} sounds
   * @property {number} tiles
   * @property {number} tokens
   * @property {number} actors
   * @property {number} items
   * @property {number} journals
   * @property {number} tables
   * @property {number} playlists
   * @property {number} packs
   * @property {number} messages
   * @property {number} performanceMode
   * @property {boolean} hasViewedScene
   * @property {string[]} worldScripts
   * @property {{width: number, height: number, [src]: string}} largestTexture
   */

  /**
   * Collects a number of metrics that is useful for Support
   * @returns {Promise<SupportReportData>}
   */
  static async generateSupportReport() {

    // Create a WebGL Context if necessary
    let tempCanvas;
    let gl = canvas.app?.renderer?.gl;
    if ( !gl ) {
      const tempCanvas = document.createElement("canvas");
      if ( tempCanvas.getContext ) {
        gl = tempCanvas.getContext("webgl2") || tempCanvas.getContext("webgl") || tempCanvas.getContext("experimental-webgl");
      }
    }
    const rendererInfo = this.getWebGLRendererInfo(gl) ?? "Unknown Renderer";

    let os = navigator.oscpu ?? "Unknown";
    let client = navigator.userAgent;

    // Attempt to retrieve high-entropy Sec-CH headers.
    if ( navigator.userAgentData ) {
      const secCH = await navigator.userAgentData.getHighEntropyValues([
        "architecture", "model", "bitness", "platformVersion", "fullVersionList"
      ]);

      const { architecture, bitness, brands, platform, platformVersion, fullVersionList } = secCH;
      os = [platform, platformVersion, architecture, bitness ? `(${bitness}-bit)` : null].filterJoin(" ");
      const { brand, version } = fullVersionList?.[0] ?? brands?.[0] ?? {};
      client = `${brand}/${version}`;
    }

    // Build report data
    const viewedScene = game.scenes.get(game.user.viewedScene);
    /** @type {Partial<SupportReportData>} **/
    const report = {
      os, client,
      coreVersion: `${game.release.display}, ${game.release.version}`,
      systemVersion: `${game.system.id}, ${game.system.version}`,
      activeModuleCount: Array.from(game.modules.values()).filter(x => x.active).length,
      performanceMode: game.settings.get("core", "performanceMode"),
      gpu: rendererInfo,
      maxTextureSize: gl && gl.getParameter ? gl.getParameter(gl.MAX_TEXTURE_SIZE) : "Could not detect",
      hasViewedScene: !!viewedScene,
      packs: game.packs.size,
      worldScripts: Array.from(game.world.esmodules).concat(...game.world.scripts).map(s => `"${s}"`).join(", ")
    };

    // Attach Document Collection counts
    const reportCollections = ["actors", "items", "journal", "tables", "playlists", "messages"];
    for ( let c of reportCollections ) {
      const collection = game[c];
      report[c] = `${collection.size}${collection.invalidDocumentIds.size > 0 ?
        ` (${collection.invalidDocumentIds.size} ${game.i18n.localize("Invalid")})` : ""}`;
    }

    if ( viewedScene ) {
      report.sceneDimensions = `${viewedScene.dimensions.width} x ${viewedScene.dimensions.height}`;
      report.grid = viewedScene.grid.size;
      report.padding = viewedScene.padding;
      report.walls = viewedScene.walls.size;
      report.lights = viewedScene.lights.size;
      report.sounds = viewedScene.sounds.size;
      report.tiles = viewedScene.tiles.size;
      report.tokens = viewedScene.tokens.size;
      report.largestTexture = SupportDetails.#getLargestTexture();
    }

    // Clean up temporary canvas
    if ( tempCanvas ) tempCanvas.remove();
    return report;
  }

  /* -------------------------------------------- */

  /**
   * Find the largest texture in the scene.
   * @returns {{width: number, height: number, [src]: string}}
   */
  static #getLargestTexture() {
    let largestTexture = { width: 0, height: 0 };

    /**
     * Find any textures in the given DisplayObject or its children.
     * @param {DisplayObject} obj  The object.
     */
    function findTextures(obj) {
      if ( (obj instanceof PIXI.Sprite) || (obj instanceof SpriteMesh) || (obj instanceof PrimarySpriteMesh) ) {
        const texture = obj.texture?.baseTexture ?? {};
        const { width, height, resource } = texture;
        if ( Math.max(width, height) > Math.max(largestTexture.width, largestTexture.height) ) {
          largestTexture = { width, height, src: resource?.src };
        }
      }
      (obj?.children ?? []).forEach(findTextures);
    }

    findTextures(canvas.stage);
    return largestTexture;
  }

  /* -------------------------------------------- */

  /**
   * Get a WebGL renderer information string
   * @param {WebGLRenderingContext} gl    The rendering context
   * @returns {string}                    The unmasked renderer string
   */
  static getWebGLRendererInfo(gl) {
    if ( navigator.userAgent.match(/Firefox\/([0-9]+)\./) ) {
      return gl.getParameter(gl.RENDERER);
    } else {
      return gl.getParameter(gl.getExtension("WEBGL_debug_renderer_info").UNMASKED_RENDERER_WEBGL);
    }
  }
}

/**
 * A management app for configuring which Tours are available or have been completed.
 */
class ToursManagement extends PackageConfiguration {

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "tours-management",
      title: game.i18n.localize("SETTINGS.Tours"),
      categoryTemplate: "templates/sidebar/apps/tours-management-category.html"
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareCategoryData() {

    // Classify all Actions
    let categories = new Map();
    let total = 0;
    for ( let tour of game.tours.values() ) {
      if ( !tour.config.display || (tour.config.restricted && !game.user.isGM) ) continue;
      total++;

      // Determine what category the action belongs to
      let category = this._categorizeEntry(tour.namespace);

      // Convert Tour to render data
      const tourData = {};
      tourData.category = category.title;
      tourData.id = `${tour.namespace}.${tour.id}`;
      tourData.title = game.i18n.localize(tour.title);
      tourData.description = game.i18n.localize(tour.description);
      tourData.cssClass = tour.config.restricted ? "gm" : "";
      tourData.notes = [
        tour.config.restricted ? game.i18n.localize("KEYBINDINGS.Restricted") : "",
        tour.description
      ].filterJoin("<br>");

      switch ( tour.status ) {
        case Tour.STATUS.UNSTARTED: {
          tourData.status = game.i18n.localize("TOURS.NotStarted");
          tourData.canBePlayed = tour.canStart;
          tourData.canBeReset = false;
          tourData.startOrResume = game.i18n.localize("TOURS.Start");
          break;
        }
        case Tour.STATUS.IN_PROGRESS: {
          tourData.status = game.i18n.format("TOURS.InProgress", {
            current: tour.stepIndex + 1,
            total: tour.steps.length ?? 0
          });
          tourData.canBePlayed = tour.canStart;
          tourData.canBeReset = true;
          tourData.startOrResume = game.i18n.localize(`TOURS.${tour.config.canBeResumed ? "Resume" : "Restart"}`);
          break;
        }
        case Tour.STATUS.COMPLETED: {
          tourData.status = game.i18n.localize("TOURS.Completed");
          tourData.canBeReset = true;
          tourData.cssClass += " completed";
          break;
        }
      }

      // Register a category the first time it is seen, otherwise add to it
      if ( !categories.has(category.id) ) {
        categories.set(category.id, {
          id: category.id,
          title: category.title,
          tours: [tourData],
          count: 0
        });

      } else categories.get(category.id).tours.push(tourData);
    }

    // Sort Actions by priority and assign Counts
    for ( let category of categories.values() ) {
      category.count = category.tours.length;
    }
    categories = Array.from(categories.values()).sort(this._sortCategories.bind(this));
    return {categories, total};
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".controls").on("click", ".control", this._onClickControl.bind(this));
  }

  /* -------------------------------------------- */

  /** @override */
  async _onResetDefaults(event) {
    return Dialog.confirm({
      title: game.i18n.localize("TOURS.ResetTitle"),
      content: `<p>${game.i18n.localize("TOURS.ResetWarning")}</p>`,
      yes: async () => {
        await Promise.all(game.tours.contents.map(tour => tour.reset()));
        ui.notifications.info("TOURS.ResetSuccess", {localize: true});
        this.render(true);
      },
      no: () => {},
      defaultYes: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle Control clicks
   * @param {MouseEvent} event
   * @private
   */
  _onClickControl(event) {
    const button = event.currentTarget;
    const div = button.closest(".tour");
    const tour = game.tours.get(div.dataset.tour);
    switch ( button.dataset.action ) {
      case "play":
        this.close();
        return tour.start();
      case "reset": return tour.reset();
    }
  }
}

/**
 * @typedef {FormApplicationOptions} WorldConfigOptions
 * @property {boolean} [create=false]  Whether the world is being created or updated.
 */

/**
 * The World Management setup application
 * @param {World} object                      The world being configured.
 * @param {WorldConfigOptions} [options]      Application configuration options.
 */
class WorldConfig extends FormApplication {
  /**
   * @override
   * @returns {WorldConfigOptions}
   */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "world-config",
      template: "templates/setup/world-config.hbs",
      width: 620,
      height: "auto",
      create: false
    });
  }

  /**
   * A semantic alias for the World object which is being configured by this form.
   * @type {World}
   */
  get world() {
    return this.object;
  }

  /**
   * The website knowledge base URL.
   * @type {string}
   * @private
   */
  static #WORLD_KB_URL = "https://foundryvtt.com/article/game-worlds/";

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return this.options.create ? game.i18n.localize("WORLD.TitleCreate")
      : `${game.i18n.localize("WORLD.TitleEdit")}: ${this.world.title}`;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find('[name="title"]').on("input", this.#onTitleChange.bind(this));
  }

  /* -------------------------------------------- */

  /** @override */
  getData(options={}) {
    const ac = CONST.PACKAGE_AVAILABILITY_CODES;
    const nextDate = new Date(this.world.nextSession || undefined);
    const context = {
      world: this.world,
      isCreate: this.options.create,
      submitText: game.i18n.localize(this.options.create ? "WORLD.TitleCreate" : "WORLD.SubmitEdit"),
      nextDate: nextDate.isValid() ? nextDate.toDateInputString() : "",
      nextTime: nextDate.isValid() ? nextDate.toTimeInputString() : "",
      worldKbUrl: WorldConfig.#WORLD_KB_URL,
      inWorld: !!game.world,
      themes: CONST.WORLD_JOIN_THEMES
    };
    context.showEditFields = !context.isCreate && !context.inWorld;
    if ( game.systems ) {
      context.systems = game.systems.filter(system => {
        if ( this.world.system === system.id ) return true;
        return ( system.availability <= ac.UNVERIFIED_GENERATION );
      }).sort((a, b) => a.title.localeCompare(b.title, game.i18n.lang));
    }
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getSubmitData(updateData={}) {
    const data = super._getSubmitData(updateData);

    // Augment submission actions
    if ( this.options.create ) {
      data.action = "createWorld";
      if ( !data.id.length ) data.id = data.title.slugify({strict: true});
    }
    else {
      data.id = this.world.id;
      if ( !data.resetKeys ) delete data.resetKeys;
      if ( !data.safeMode ) delete data.safeMode;
    }

    // Handle next session schedule fields
    if ( data.nextSession.some(t => !!t) ) {
      const now = new Date();
      const dateStr = `${data.nextSession[0] || now.toDateString()} ${data.nextSession[1] || now.toTimeString()}`;
      const date = new Date(dateStr);
      data.nextSession = isNaN(Number(date)) ? null : date.toISOString();
    }
    else data.nextSession = null;

    if ( data.joinTheme === CONST.WORLD_JOIN_THEMES.default ) delete data.joinTheme;
    return data;
  }

  /* -------------------------------------------- */

  /** @override */
  async _updateObject(event, formData) {
    formData = foundry.utils.expandObject(formData);
    const form = event.target || this.form;
    form.disable = true;

    // Validate the submission data
    try {
      this.world.validate({changes: formData, clean: true});
      formData.action = this.options.create ? "createWorld" : "editWorld";
    } catch(err) {
      ui.notifications.error(err.message.replace("\n", ". "));
      throw err;
    }

    // Dispatch the POST request
    let response;
    try {
      response = await foundry.utils.fetchJsonWithTimeout(foundry.utils.getRoute("setup"), {
        method: "POST",
        headers: {"Content-Type": "application/json"},
        body: JSON.stringify(formData)
      });
      form.disabled = false;

      // Display error messages
      if (response.error) return ui.notifications.error(response.error);
    }
    catch(e) {
      return ui.notifications.error(e);
    }

    // Handle successful creation
    if ( formData.action === "createWorld" ) {
      const world = new this.world.constructor(response);
      game.worlds.set(world.id, world);
    }
    else this.world.updateSource(response);
    if ( ui.setup ) ui.setup.refresh(); // TODO old V10
    if ( ui.setupPackages ) ui.setupPackages.render(); // New v11
  }

  /* -------------------------------------------- */

  /**
   * Update the world name placeholder when the title is changed.
   * @param {Event} event       The input change event
   * @private
   */
  #onTitleChange(event) {
    let slug = this.form.elements.title.value.slugify({strict: true});
    if ( !slug.length ) slug = "world-name";
    this.form.elements.id?.setAttribute("placeholder", slug);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async activateEditor(name, options={}, initialContent="") {
    const toolbar = CONFIG.TinyMCE.toolbar.split(" ").filter(t => t !== "save").join(" ");
    foundry.utils.mergeObject(options, {toolbar});
    return super.activateEditor(name, options, initialContent);
  }
}

/**
 * The sidebar directory which organizes and displays world-level Actor documents.
 */
class ActorDirectory extends DocumentDirectory {
  constructor(...args) {
    super(...args);
    this._dragDrop[0].permissions.dragstart = () => game.user.can("TOKEN_CREATE");
    this._dragDrop[0].permissions.drop = () => game.user.can("ACTOR_CREATE");
  }

  /* -------------------------------------------- */

  /** @override */
  static documentName = "Actor";

  /* -------------------------------------------- */

  /** @override */
  _canDragStart(selector) {
    return game.user.can("TOKEN_CREATE");
  }

  /* -------------------------------------------- */

  /** @override */
  _onDragStart(event) {
    const li = event.currentTarget.closest(".directory-item");
    let actor = null;
    if ( li.dataset.documentId ) {
      actor = game.actors.get(li.dataset.documentId);
      if ( !actor || !actor.visible ) return false;
    }

    // Parent directory drag start handling
    super._onDragStart(event);

    // Create the drag preview for the Token
    if ( actor && canvas.ready ) {
      const img = li.querySelector("img");
      const pt = actor.prototypeToken;
      const w = pt.width * canvas.dimensions.size * Math.abs(pt.texture.scaleX) * canvas.stage.scale.x;
      const h = pt.height * canvas.dimensions.size * Math.abs(pt.texture.scaleY) * canvas.stage.scale.y;
      const preview = DragDrop.createDragImage(img, w, h);
      event.dataTransfer.setDragImage(preview, w / 2, h / 2);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _canDragDrop(selector) {
    return game.user.can("ACTOR_CREATE");
  }

  /* -------------------------------------------- */

  /** @override */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    return [
      {
        name: "SIDEBAR.CharArt",
        icon: '<i class="fas fa-image"></i>',
        condition: li => {
          const actor = game.actors.get(li.data("documentId"));
          return actor.img !== CONST.DEFAULT_TOKEN;
        },
        callback: li => {
          const actor = game.actors.get(li.data("documentId"));
          new ImagePopout(actor.img, {
            title: actor.name,
            uuid: actor.uuid
          }).render(true);
        }
      },
      {
        name: "SIDEBAR.TokenArt",
        icon: '<i class="fas fa-image"></i>',
        condition: li => {
          const actor = game.actors.get(li.data("documentId"));
          if ( actor.prototypeToken.randomImg ) return false;
          return ![null, undefined, CONST.DEFAULT_TOKEN].includes(actor.prototypeToken.texture.src);
        },
        callback: li => {
          const actor = game.actors.get(li.data("documentId"));
          new ImagePopout(actor.prototypeToken.texture.src, {
            title: actor.name,
            uuid: actor.uuid
          }).render(true);
        }
      }
    ].concat(options);
  }
}

/**
 * The sidebar directory which organizes and displays world-level Cards documents.
 * @extends {DocumentDirectory}
 */
class CardsDirectory extends DocumentDirectory {

  /** @override */
  static documentName = "Cards";

  /** @inheritDoc */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    const duplicate = options.find(o => o.name === "SIDEBAR.Duplicate");
    duplicate.condition = li => {
      if ( !game.user.isGM ) return false;
      const cards = this.constructor.collection.get(li.data("documentId"));
      return cards.canClone;
    };
    return options;
  }
}

/**
 * @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());

      // 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   The initial scroll event
   * @private
   */
  _onScrollLog(event) {
    if ( !this.rendered ) return;
    const log = event.target;
    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;
      }
    }
  }
}

/**
 * The sidebar directory which organizes and displays world-level Combat documents.
 */
class CombatTracker extends SidebarTab {
  constructor(options) {
    super(options);
    if ( !this.popOut ) game.combats.apps.push(this);

    /**
     * Record a reference to the currently highlighted Token
     * @type {Token|null}
     * @private
     */
    this._highlighted = null;

    /**
     * Record the currently tracked Combat encounter
     * @type {Combat|null}
     */
    this.viewed = null;

    // Initialize the starting encounter
    this.initialize({render: false});
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "combat",
      template: "templates/sidebar/combat-tracker.html",
      title: "COMBAT.SidebarTitle",
      scrollY: [".directory-list"]
    });
  }

  /* -------------------------------------------- */

  /**
   * Return an array of Combat encounters which occur within the current Scene.
   * @type {Combat[]}
   */
  get combats() {
    return game.combats.combats;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritdoc */
  createPopout() {
    const pop = super.createPopout();
    pop.initialize({combat: this.viewed, render: true});
    return pop;
  }

  /* -------------------------------------------- */

  /**
   * Initialize the combat tracker to display a specific combat encounter.
   * If no encounter is provided, the tracker will be initialized with the first encounter in the viewed scene.
   * @param {object} [options]                   Additional options to configure behavior.
   * @param {Combat|null} [options.combat=null]  The combat encounter to initialize
   * @param {boolean} [options.render=true]      Whether to re-render the sidebar after initialization
   */
  initialize({combat=null, render=true}={}) {

    // Retrieve a default encounter if none was provided
    if ( combat === null ) {
      const combats = this.combats;
      combat = combats.length ? combats.find(c => c.active) || combats[0] : null;
      combat?.updateCombatantActors();
    }

    // Prepare turn order
    if ( combat && !combat.turns ) combat.turns = combat.setupTurns();

    // Set flags
    this.viewed = combat;
    this._highlighted = null;

    // Also initialize the popout
    if ( this._popout ) {
      this._popout.viewed = combat;
      this._popout._highlighted = null;
    }

    // Render the tracker
    if ( render ) this.render();
  }

  /* -------------------------------------------- */

  /**
   * Scroll the combat log container to ensure the current Combatant turn is centered vertically
   */
  scrollToTurn() {
    const combat = this.viewed;
    if ( !combat || (combat.turn === null) ) return;
    let active = this.element.find(".active")[0];
    if ( !active ) return;
    let container = active.parentElement;
    const nViewable = Math.floor(container.offsetHeight / active.offsetHeight);
    container.scrollTop = (combat.turn * active.offsetHeight) - ((nViewable/2) * active.offsetHeight);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async getData(options={}) {
    let context = await super.getData(options);

    // Get the combat encounters possible for the viewed Scene
    const combat = this.viewed;
    const hasCombat = combat !== null;
    const combats = this.combats;
    const currentIdx = combats.findIndex(c => c === combat);
    const previousId = currentIdx > 0 ? combats[currentIdx-1].id : null;
    const nextId = currentIdx < combats.length - 1 ? combats[currentIdx+1].id : null;
    const settings = game.settings.get("core", Combat.CONFIG_SETTING);

    // Prepare rendering data
    context = foundry.utils.mergeObject(context, {
      combats: combats,
      currentIndex: currentIdx + 1,
      combatCount: combats.length,
      hasCombat: hasCombat,
      combat,
      turns: [],
      previousId,
      nextId,
      started: this.started,
      control: false,
      settings,
      linked: combat?.scene !== null,
      labels: {}
    });
    context.labels.scope = game.i18n.localize(`COMBAT.${context.linked ? "Linked" : "Unlinked"}`);
    if ( !hasCombat ) return context;

    // Format information about each combatant in the encounter
    let hasDecimals = false;
    const turns = [];
    for ( let [i, combatant] of combat.turns.entries() ) {
      if ( !combatant.visible ) continue;

      // Prepare turn data
      const resource = combatant.permission >= CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER ? combatant.resource : null;
      const turn = {
        id: combatant.id,
        name: combatant.name,
        img: await this._getCombatantThumbnail(combatant),
        active: i === combat.turn,
        owner: combatant.isOwner,
        defeated: combatant.isDefeated,
        hidden: combatant.hidden,
        initiative: combatant.initiative,
        hasRolled: combatant.initiative !== null,
        hasResource: resource !== null,
        resource: resource,
        canPing: (combatant.sceneId === canvas.scene?.id) && game.user.hasPermission("PING_CANVAS")
      };
      if ( (turn.initiative !== null) && !Number.isInteger(turn.initiative) ) hasDecimals = true;
      turn.css = [
        turn.active ? "active" : "",
        turn.hidden ? "hidden" : "",
        turn.defeated ? "defeated" : ""
      ].join(" ").trim();

      // Actor and Token status effects
      turn.effects = new Set();
      for ( const effect of (combatant.actor?.temporaryEffects || []) ) {
        if ( effect.statuses.has(CONFIG.specialStatusEffects.DEFEATED) ) turn.defeated = true;
        else if ( effect.img ) turn.effects.add(effect.img);
      }
      turns.push(turn);
    }

    // Format initiative numeric precision
    const precision = CONFIG.Combat.initiative.decimals;
    turns.forEach(t => {
      if ( t.initiative !== null ) t.initiative = t.initiative.toFixed(hasDecimals ? precision : 0);
    });

    // Confirm user permission to advance
    const isPlayerTurn = combat.combatant?.players?.includes(game.user);
    const canControl = combat.turn && combat.turn.between(1, combat.turns.length - 2)
      ? combat.canUserModify(game.user, "update", {turn: 0})
      : combat.canUserModify(game.user, "update", {round: 0});

    // Merge update data for rendering
    return foundry.utils.mergeObject(context, {
      round: combat.round,
      turn: combat.turn,
      turns: turns,
      control: isPlayerTurn && canControl
    });
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a source image for a combatant.
   * @param {Combatant} combatant         The combatant queried for image.
   * @returns {Promise<string>}           The source image attributed for this combatant.
   * @protected
   */
  async _getCombatantThumbnail(combatant) {
    if ( combatant._videoSrc && !combatant.img ) {
      if ( combatant._thumb ) return combatant._thumb;
      return combatant._thumb = await game.video.createThumbnail(combatant._videoSrc, {width: 100, height: 100});
    }
    return combatant.img ?? CONST.DEFAULT_TOKEN;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(html) {
    super.activateListeners(html);
    const tracker = html.find("#combat-tracker");
    const combatants = tracker.find(".combatant");

    // Create new Combat encounter
    html.find(".combat-create").click(ev => this._onCombatCreate(ev));

    // Display Combat settings
    html.find(".combat-settings").click(ev => {
      ev.preventDefault();
      new CombatTrackerConfig().render(true);
    });

    // Cycle the current Combat encounter
    html.find(".combat-cycle").click(ev => this._onCombatCycle(ev));

    // Combat control
    html.find(".combat-control").click(ev => this._onCombatControl(ev));

    // Combatant control
    html.find(".combatant-control").click(ev => this._onCombatantControl(ev));

    // Hover on Combatant
    combatants.hover(this._onCombatantHoverIn.bind(this), this._onCombatantHoverOut.bind(this));

    // Click on Combatant
    combatants.click(this._onCombatantMouseDown.bind(this));

    // Context on right-click
    if ( game.user.isGM ) this._contextMenu(html);

    // Intersection Observer for Combatant avatars
    const observer = new IntersectionObserver(this._onLazyLoadImage.bind(this), {root: tracker[0]});
    combatants.each((i, li) => observer.observe(li));
  }

  /* -------------------------------------------- */

  /**
   * Handle new Combat creation request
   * @param {Event} event
   * @private
   */
  async _onCombatCreate(event) {
    event.preventDefault();
    let scene = game.scenes.current;
    const cls = getDocumentClass("Combat");
    await cls.create({scene: scene?.id, active: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle a Combat cycle request
   * @param {Event} event
   * @private
   */
  async _onCombatCycle(event) {
    event.preventDefault();
    const btn = event.currentTarget;
    const combat = game.combats.get(btn.dataset.documentId);
    if ( !combat ) return;
    await combat.activate({render: false});
  }

  /* -------------------------------------------- */

  /**
   * Handle click events on Combat control buttons
   * @private
   * @param {Event} event   The originating mousedown event
   */
  async _onCombatControl(event) {
    event.preventDefault();
    const combat = this.viewed;
    const ctrl = event.currentTarget;
    if ( ctrl.getAttribute("disabled") ) return;
    else ctrl.setAttribute("disabled", true);
    try {
      const fn = combat[ctrl.dataset.control];
      if ( fn ) await fn.bind(combat)();
    } finally {
      ctrl.removeAttribute("disabled");
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle a Combatant control toggle
   * @private
   * @param {Event} event   The originating mousedown event
   */
  async _onCombatantControl(event) {
    event.preventDefault();
    event.stopPropagation();
    const btn = event.currentTarget;
    const li = btn.closest(".combatant");
    const combat = this.viewed;
    const c = combat.combatants.get(li.dataset.combatantId);

    // Switch control action
    switch ( btn.dataset.control ) {

      // Toggle combatant visibility
      case "toggleHidden":
        return c.update({hidden: !c.hidden});

      // Toggle combatant defeated flag
      case "toggleDefeated":
        return this._onToggleDefeatedStatus(c);

      // Roll combatant initiative
      case "rollInitiative":
        return combat.rollInitiative([c.id]);

      // Actively ping the Combatant
      case "pingCombatant":
        return this._onPingCombatant(c);

      case "panToCombatant":
        return this._onPanToCombatant(c);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the defeated status effect on a combatant Token
   * @param {Combatant} combatant     The combatant data being modified
   * @returns {Promise}                A Promise that resolves after all operations are complete
   * @private
   */
  async _onToggleDefeatedStatus(combatant) {
    const isDefeated = !combatant.isDefeated;
    await combatant.update({defeated: isDefeated});
    const defeatedId = CONFIG.specialStatusEffects.DEFEATED;
    await combatant.actor?.toggleStatusEffect(defeatedId, {overlay: true, active: isDefeated});
  }

  /* -------------------------------------------- */

  /**
   * Handle pinging a combatant Token
   * @param {Combatant} combatant     The combatant data
   * @returns {Promise}
   * @protected
   */
  async _onPingCombatant(combatant) {
    if ( !canvas.ready || (combatant.sceneId !== canvas.scene.id) ) return;
    if ( !combatant.token.object.visible ) return ui.notifications.warn(game.i18n.localize("COMBAT.WarnNonVisibleToken"));
    await canvas.ping(combatant.token.object.center);
  }

  /* -------------------------------------------- */

  /**
   * Handle panning to a combatant Token
   * @param {Combatant} combatant     The combatant data
   * @returns {Promise}
   * @protected
   */
  async _onPanToCombatant(combatant) {
    if ( !canvas.ready || (combatant.sceneId !== canvas.scene.id) ) return;
    if ( !combatant.token.object.visible ) return ui.notifications.warn(game.i18n.localize("COMBAT.WarnNonVisibleToken"));
    const {x, y} = combatant.token.object.center;
    await canvas.animatePan({x, y, scale: Math.max(canvas.stage.scale.x, 0.5)});
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-down event on a combatant name in the tracker
   * @param {Event} event   The originating mousedown event
   * @returns {Promise}     A Promise that resolves once the pan is complete
   * @private
   */
  async _onCombatantMouseDown(event) {
    event.preventDefault();

    const li = event.currentTarget;
    const combatant = this.viewed.combatants.get(li.dataset.combatantId);
    const token = combatant.token;
    if ( !combatant.actor?.testUserPermission(game.user, "OBSERVER") ) return;
    const now = Date.now();

    // Handle double-left click to open sheet
    const dt = now - this._clickTime;
    this._clickTime = now;
    if ( dt <= 250 ) return combatant.actor?.sheet.render(true);

    // Control and pan to Token object
    if ( token?.object ) {
      token.object?.control({releaseOthers: true});
      return canvas.animatePan(token.object.center);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-hover events on a combatant in the tracker
   * @private
   */
  _onCombatantHoverIn(event) {
    event.preventDefault();
    if ( !canvas.ready ) return;
    const li = event.currentTarget;
    const combatant = this.viewed.combatants.get(li.dataset.combatantId);
    const token = combatant.token?.object;
    if ( token?.isVisible ) {
      if ( !token.controlled ) token._onHoverIn(event, {hoverOutOthers: true});
      this._highlighted = token;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse-unhover events for a combatant in the tracker
   * @private
   */
  _onCombatantHoverOut(event) {
    event.preventDefault();
    if ( this._highlighted ) this._highlighted._onHoverOut(event);
    this._highlighted = null;
  }

  /* -------------------------------------------- */

  /**
   * Highlight a hovered combatant in the tracker.
   * @param {Combatant} combatant The Combatant
   * @param {boolean} hover       Whether they are being hovered in or out.
   */
  hoverCombatant(combatant, hover) {
    const trackers = [this.element[0]];
    if ( this._popout ) trackers.push(this._popout.element[0]);
    for ( const tracker of trackers ) {
      const li = tracker.querySelector(`.combatant[data-combatant-id="${combatant.id}"]`);
      if ( !li ) continue;
      if ( hover ) li.classList.add("hover");
      else li.classList.remove("hover");
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _contextMenu(html) {
    ContextMenu.create(this, html, ".directory-item", this._getEntryContextOptions());
  }

  /* -------------------------------------------- */

  /**
   * Get the Combatant entry context options
   * @returns {object[]}   The Combatant entry context options
   * @private
   */
  _getEntryContextOptions() {
    return [
      {
        name: "COMBAT.CombatantUpdate",
        icon: '<i class="fas fa-edit"></i>',
        callback: this._onConfigureCombatant.bind(this)
      },
      {
        name: "COMBAT.CombatantClear",
        icon: '<i class="fas fa-undo"></i>',
        condition: li => {
          const combatant = this.viewed.combatants.get(li.data("combatant-id"));
          return Number.isNumeric(combatant?.initiative);
        },
        callback: li => {
          const combatant = this.viewed.combatants.get(li.data("combatant-id"));
          if ( combatant ) return combatant.update({initiative: null});
        }
      },
      {
        name: "COMBAT.CombatantReroll",
        icon: '<i class="fas fa-dice-d20"></i>',
        callback: li => {
          const combatant = this.viewed.combatants.get(li.data("combatant-id"));
          if ( combatant ) return this.viewed.rollInitiative([combatant.id]);
        }
      },
      {
        name: "COMBAT.CombatantRemove",
        icon: '<i class="fas fa-trash"></i>',
        callback: li => {
          const combatant = this.viewed.combatants.get(li.data("combatant-id"));
          if ( combatant ) return combatant.delete();
        }
      }
    ];
  }

  /* -------------------------------------------- */

  /**
   * Display a dialog which prompts the user to enter a new initiative value for a Combatant
   * @param {jQuery} li
   * @private
   */
  _onConfigureCombatant(li) {
    const combatant = this.viewed.combatants.get(li.data("combatant-id"));
    new CombatantConfig(combatant, {
      top: Math.min(li[0].offsetTop, window.innerHeight - 350),
      left: window.innerWidth - 720,
      width: 400
    }).render(true);
  }
}

/**
 * A compendium of knowledge arcane and mystical!
 * Renders the sidebar directory of compendium packs
 * @extends {SidebarTab}
 * @mixes {DirectoryApplication}
 */
class CompendiumDirectory extends DirectoryApplicationMixin(SidebarTab) {

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "compendium",
      template: "templates/sidebar/compendium-directory.html",
      title: "COMPENDIUM.SidebarTitle",
      contextMenuSelector: ".directory-item.compendium",
      entryClickSelector: ".compendium"
    });
  }

  /**
   * A reference to the currently active compendium types. If empty, all types are shown.
   * @type {string[]}
   */
  #activeFilters = [];

  get activeFilters() {
    return this.#activeFilters;
  }

  /* -------------------------------------------- */

  /** @override */
  entryType = "Compendium";

  /* -------------------------------------------- */

  /** @override */
  static entryPartial = "templates/sidebar/partials/pack-partial.html";

  /* -------------------------------------------- */

  /** @override */
  _entryAlreadyExists(entry) {
    return this.collection.has(entry.collection);
  }

  /* -------------------------------------------- */

  /** @override */
  _getEntryDragData(entryId) {
    const pack = this.collection.get(entryId);
    return {
      type: "Compendium",
      id: pack.collection
    };
  }

  /* -------------------------------------------- */

  /** @override */
  _entryIsSelf(entry, otherEntry) {
    return entry.metadata.id === otherEntry.metadata.id;
  }

  /* -------------------------------------------- */

  /** @override */
  async _sortRelative(entry, sortData) {
    // We build up a single update object for all compendiums to prevent multiple re-renders
    const packConfig = game.settings.get("core", "compendiumConfiguration");
    const targetFolderId = sortData.updateData.folder;
    packConfig[entry.collection] = foundry.utils.mergeObject(packConfig[entry.collection] || {}, {
      folder: targetFolderId
    });

    // Update sorting
    const sorting = SortingHelpers.performIntegerSort(entry, sortData);
    for ( const s of sorting ) {
      const pack = s.target;
      const existingConfig = packConfig[pack.collection] || {};
      existingConfig.sort = s.update.sort;
    }
    await game.settings.set("core", "compendiumConfiguration", packConfig);
  }

  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".filter").click(this._displayFilterCompendiumMenu.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Display a menu of compendium types to filter by
   * @param {PointerEvent} event    The originating pointer event
   * @returns {Promise<void>}
   * @protected
   */
  async _displayFilterCompendiumMenu(event) {
    // If there is a current dropdown menu, remove it
    const dropdown = document.getElementsByClassName("dropdown-menu")[0];
    if ( dropdown ) {
      dropdown.remove();
      return;
    }
    const button = event.currentTarget;

    // Display a menu of compendium types to filter by
    const choices = CONST.COMPENDIUM_DOCUMENT_TYPES.map(t => {
      const config = CONFIG[t];
      return {
        name: game.i18n.localize(config.documentClass.metadata.label),
        icon: config.sidebarIcon,
        type: t,
        callback: (event) => this._onToggleCompendiumFilterType(event, t)
      };
    });

    // If there are active filters, add a "Clear Filters" option
    if ( this.#activeFilters.length ) {
      choices.unshift({
        name: game.i18n.localize("COMPENDIUM.ClearFilters"),
        icon: "fas fa-times",
        type: null,
        callback: (event) => this._onToggleCompendiumFilterType(event, null)
      });
    }

    // Create a vertical list of buttons contained in a div
    const menu = document.createElement("div");
    menu.classList.add("dropdown-menu");
    const list = document.createElement("div");
    list.classList.add("dropdown-list", "flexcol");
    menu.appendChild(list);
    for ( let c of choices ) {
      const dropdownItem = document.createElement("a");
      dropdownItem.classList.add("dropdown-item");
      if ( this.#activeFilters.includes(c.type) ) dropdownItem.classList.add("active");
      dropdownItem.innerHTML = `<i class="${c.icon}"></i> ${c.name}`;
      dropdownItem.addEventListener("click", c.callback);
      list.appendChild(dropdownItem);
    }

    // Position the menu
    const pos = {
      top: button.offsetTop + 10,
      left: button.offsetLeft + 10
    };
    menu.style.top = `${pos.top}px`;
    menu.style.left = `${pos.left}px`;
    button.parentElement.appendChild(menu);
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a compendium type filter
   * @param {PointerEvent} event    The originating pointer event
   * @param {string|null} type      The compendium type to filter by. If null, clear all filters.
   * @protected
   */
  _onToggleCompendiumFilterType(event, type) {
    if ( type === null ) this.#activeFilters = [];
    else this.#activeFilters = this.#activeFilters.includes(type) ?
      this.#activeFilters.filter(t => t !== type) : this.#activeFilters.concat(type);
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * The collection of Compendium Packs which are displayed in this Directory
   * @returns {CompendiumPacks<string, CompendiumCollection>}
   */
  get collection() {
    return game.packs;
  }

  /* -------------------------------------------- */

  /**
   * Get the dropped Entry from the drop data
   * @param {object} data         The data being dropped
   * @returns {Promise<object>}   The dropped Entry
   * @protected
   */
  async _getDroppedEntryFromData(data) {
    return game.packs.get(data.id);
  }

  /* -------------------------------------------- */

  /** @override */
  async _createDroppedEntry(document, folder) {
    throw new Error("The _createDroppedEntry shouldn't be called for CompendiumDirectory");
  }

  /* -------------------------------------------- */

  /** @override */
  _getEntryName(entry) {
    return entry.metadata.label;
  }

  /* -------------------------------------------- */

  /** @override */
  _getEntryId(entry) {
    return entry.metadata.id;
  }

  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    let context = await super.getData(options);

    // For each document, assign a default image if one is not already present, and calculate the style string
    const packageTypeIcons = {
      "world": World.icon,
      "system": System.icon,
      "module": Module.icon
    };
    const packContext = {};
    for ( const pack of this.collection ) {
      packContext[pack.collection] = {
        locked: pack.locked,
        customOwnership: "ownership" in pack.config,
        collection: pack.collection,
        name: pack.metadata.packageName,
        label: pack.metadata.label,
        icon: CONFIG[pack.metadata.type].sidebarIcon,
        hidden: this.#activeFilters?.length ? !this.#activeFilters.includes(pack.metadata.type) : false,
        banner: pack.banner,
        sourceIcon: packageTypeIcons[pack.metadata.packageType]
      };
    }

    // Return data to the sidebar
    context = foundry.utils.mergeObject(context, {
      folderIcon: CONFIG.Folder.sidebarIcon,
      label: game.i18n.localize("PACKAGE.TagCompendium"),
      labelPlural: game.i18n.localize("SIDEBAR.TabCompendium"),
      sidebarIcon: "fas fa-atlas",
      filtersActive: !!this.#activeFilters.length
    });
    context.packContext = packContext;
    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  async render(force=false, options={}) {
    game.packs.initializeTree();
    return super.render(force, options);
  }

  /* -------------------------------------------- */

  /** @override */
  _getEntryContextOptions() {
    if ( !game.user.isGM ) return [];
    return [
      {
        name: "OWNERSHIP.Configure",
        icon: '<i class="fa-solid fa-user-lock"></i>',
        callback: li => {
          const pack = game.packs.get(li.data("pack"));
          return pack.configureOwnershipDialog();
        }
      },
      {
        name: "FOLDER.Clear",
        icon: '<i class="fas fa-folder"></i>',
        condition: header => {
          const li = header.closest(".directory-item");
          const entry = this.collection.get(li.data("entryId"));
          return !!entry.folder;
        },
        callback: header => {
          const li = header.closest(".directory-item");
          const entry = this.collection.get(li.data("entryId"));
          entry.setFolder(null);
        }
      },
      {
        name: "COMPENDIUM.ToggleLocked",
        icon: '<i class="fas fa-lock"></i>',
        callback: li => {
          let pack = game.packs.get(li.data("pack"));
          const isUnlock = pack.locked;
          if ( isUnlock && (pack.metadata.packageType !== "world")) {
            return Dialog.confirm({
              title: `${game.i18n.localize("COMPENDIUM.ToggleLocked")}: ${pack.title}`,
              content: `<p><strong>${game.i18n.localize("Warning")}:</strong> ${game.i18n.localize("COMPENDIUM.ToggleLockedWarning")}</p>`,
              yes: () => pack.configure({locked: !pack.locked}),
              options: {
                top: Math.min(li[0].offsetTop, window.innerHeight - 350),
                left: window.innerWidth - 720,
                width: 400
              }
            });
          }
          else return pack.configure({locked: !pack.locked});
        }
      },
      {
        name: "COMPENDIUM.Duplicate",
        icon: '<i class="fas fa-copy"></i>',
        callback: li => {
          let pack = game.packs.get(li.data("pack"));
          const html = `<form>
            <div class="form-group">
                <label>${game.i18n.localize("COMPENDIUM.DuplicateTitle")}</label>
                <input type="text" name="label" value="${game.i18n.format("DOCUMENT.CopyOf", {name: pack.title})}"/>
                <p class="notes">${game.i18n.localize("COMPENDIUM.DuplicateHint")}</p>
            </div>
          </form>`;
          return Dialog.confirm({
            title: `${game.i18n.localize("COMPENDIUM.Duplicate")}: ${pack.title}`,
            content: html,
            yes: html => {
              const label = html.querySelector('input[name="label"]').value;
              return pack.duplicateCompendium({label});
            },
            options: {
              top: Math.min(li[0].offsetTop, window.innerHeight - 350),
              left: window.innerWidth - 720,
              width: 400,
              jQuery: false
            }
          });
        }
      },
      {
        name: "COMPENDIUM.ImportAll",
        icon: '<i class="fas fa-download"></i>',
        condition: li => game.packs.get(li.data("pack"))?.documentName !== "Adventure",
        callback: li => {
          let pack = game.packs.get(li.data("pack"));
          return pack.importDialog({
            top: Math.min(li[0].offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720,
            width: 400
          });
        }
      },
      {
        name: "COMPENDIUM.Delete",
        icon: '<i class="fas fa-trash"></i>',
        condition: li => {
          let pack = game.packs.get(li.data("pack"));
          return pack.metadata.packageType === "world";
        },
        callback: li => {
          let pack = game.packs.get(li.data("pack"));
          return this._onDeleteCompendium(pack);
        }
      }
    ];
  }

  /* -------------------------------------------- */

  /** @override */
  async _onClickEntryName(event) {
    event.preventDefault();
    const element = event.currentTarget;
    const packId = element.closest("[data-pack]").dataset.pack;
    const pack = game.packs.get(packId);
    pack.render(true);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onCreateEntry(event) {
    event.preventDefault();
    event.stopPropagation();
    const li = event.currentTarget.closest(".directory-item");
    const targetFolderId = li ? li.dataset.folderId : null;
    const types = CONST.COMPENDIUM_DOCUMENT_TYPES.map(documentName => {
      return { value: documentName, label: game.i18n.localize(getDocumentClass(documentName).metadata.label) };
    });
    game.i18n.sortObjects(types, "label");
    const folders = this.collection._formatFolderSelectOptions();
    const html = await renderTemplate("templates/sidebar/compendium-create.html",
      {types, folders, folder: targetFolderId, hasFolders: folders.length >= 1});
    return Dialog.prompt({
      title: game.i18n.localize("COMPENDIUM.Create"),
      content: html,
      label: game.i18n.localize("COMPENDIUM.Create"),
      callback: async html => {
        const form = html.querySelector("#compendium-create");
        const fd = new FormDataExtended(form);
        const metadata = fd.object;
        let targetFolderId = metadata.folder;
        if ( metadata.folder ) delete metadata.folder;
        if ( !metadata.label ) {
          let defaultName = game.i18n.format("DOCUMENT.New", {type: game.i18n.localize("PACKAGE.TagCompendium")});
          const count = game.packs.size;
          if ( count > 0 ) defaultName += ` (${count + 1})`;
          metadata.label = defaultName;
        }
        const pack = await CompendiumCollection.createCompendium(metadata);
        if ( targetFolderId ) await pack.setFolder(targetFolderId);
      },
      rejectClose: false,
      options: { jQuery: false }
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle a Compendium Pack deletion request
   * @param {object} pack   The pack object requested for deletion
   * @private
   */
  _onDeleteCompendium(pack) {
    return Dialog.confirm({
      title: `${game.i18n.localize("COMPENDIUM.Delete")}: ${pack.title}`,
      content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("COMPENDIUM.DeleteWarning")}</p>`,
      yes: () => pack.deleteCompendium(),
      defaultYes: false
    });
  }
}

/**
 * The sidebar directory which organizes and displays world-level Item documents.
 */
class ItemDirectory extends DocumentDirectory {

  /** @override */
  static documentName = "Item";

  /* -------------------------------------------- */

  /** @override */
  _canDragDrop(selector) {
    return game.user.can("ITEM_CREATE");
  }

  /* -------------------------------------------- */

  /** @override */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    return [
      {
        name: "ITEM.ViewArt",
        icon: '<i class="fas fa-image"></i>',
        condition: li => {
          const item = game.items.get(li.data("documentId"));
          return item.img !== CONST.DEFAULT_TOKEN;
        },
        callback: li => {
          const item = game.items.get(li.data("documentId"));
          new ImagePopout(item.img, {
            title: item.name,
            uuid: item.uuid
          }).render(true);
        }
      }
    ].concat(options);
  }
}

/**
 * The sidebar directory which organizes and displays world-level JournalEntry documents.
 * @extends {DocumentDirectory}
 */
class JournalDirectory extends DocumentDirectory {

  /** @override */
  static documentName = "JournalEntry";

  /* -------------------------------------------- */

  /** @override */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    return options.concat([
      {
        name: "SIDEBAR.JumpPin",
        icon: '<i class="fas fa-crosshairs"></i>',
        condition: li => {
          const entry = game.journal.get(li.data("document-id"));
          return !!entry.sceneNote;
        },
        callback: li => {
          const entry = game.journal.get(li.data("document-id"));
          return entry.panToNote();
        }
      }
    ]);
  }
}

/**
 * The directory, not displayed in the sidebar, which organizes and displays world-level Macro documents.
 * @extends {DocumentDirectory}
 *
 * @see {@link Macros}        The WorldCollection of Macro Documents
 * @see {@link Macro}         The Macro Document
 * @see {@link MacroConfig}   The Macro Configuration Sheet
 */
class MacroDirectory extends DocumentDirectory {
  constructor(options={}) {
    options.popOut = true;
    super(options);
    delete ui.sidebar.tabs["macros"];
    game.macros.apps.push(this);
  }

  /** @override */
  static documentName = "Macro";
}

/**
 * The sidebar directory which organizes and displays world-level Playlist documents.
 * @extends {DocumentDirectory}
 */
class PlaylistDirectory extends DocumentDirectory {
  constructor(options) {
    super(options);

    /**
     * Track the playlist IDs which are currently expanded in their display
     * @type {Set<string>}
     */
    this._expanded = this._createExpandedSet();

    /**
     * Are the global volume controls currently expanded?
     * @type {boolean}
     * @private
     */
    this._volumeExpanded = true;

    /**
     * Cache the set of Playlist documents that are displayed as playing when the directory is rendered
     * @type {Playlist[]}
     */
    this._playingPlaylists = [];

    /**
     * Cache the set of PlaylistSound documents that are displayed as playing when the directory is rendered
     * @type {PlaylistSound[]}
     */
    this._playingSounds = [];

    // Update timestamps every second
    setInterval(this._updateTimestamps.bind(this), 1000);

    // Playlist 'currently playing' pinned location.
    game.settings.register("core", "playlist.playingLocation", {
      scope: "client",
      config: false,
      default: "top",
      type: String,
      onChange: () => ui.playlists.render()
    });
  }

  /** @override */
  static documentName = "Playlist";

  /** @override */
  static entryPartial = "templates/sidebar/partials/playlist-partial.html";

  /* -------------------------------------------- */

  /** @override */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.template = "templates/sidebar/playlists-directory.html";
    options.dragDrop[0].dragSelector = ".folder, .playlist-name, .sound-name";
    options.renderUpdateKeys = ["name", "playing", "mode", "sounds", "sort", "sorting", "folder"];
    options.contextMenuSelector = ".document .playlist-header";
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Initialize the set of Playlists which should be displayed in an expanded form
   * @returns {Set<string>}
   * @private
   */
  _createExpandedSet() {
    const expanded = new Set();
    for ( let playlist of this.documents ) {
      if ( playlist.playing ) expanded.add(playlist.id);
    }
    return expanded;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Return an Array of the Playlist documents which are currently playing
   * @type {Playlist[]}
   */
  get playing() {
    return this._playingPlaylists;
  }

  /**
   * Whether the 'currently playing' element is pinned to the top or bottom of the display.
   * @type {string}
   * @private
   */
  get _playingLocation() {
    return game.settings.get("core", "playlist.playingLocation");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritdoc */
  async getData(options={}) {
    this._playingPlaylists = [];
    this._playingSounds = [];
    this._playingSoundsData = [];
    this._prepareTreeData(this.collection.tree);
    const data = await super.getData(options);
    const currentAtTop = this._playingLocation === "top";
    return foundry.utils.mergeObject(data, {
      playingSounds: this._playingSoundsData,
      showPlaying: this._playingSoundsData.length > 0,
      playlistModifier: foundry.audio.AudioHelper.volumeToInput(game.settings.get("core", "globalPlaylistVolume")),
      playlistTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalPlaylistVolume")),
      ambientModifier: foundry.audio.AudioHelper.volumeToInput(game.settings.get("core", "globalAmbientVolume")),
      ambientTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalAmbientVolume")),
      interfaceModifier: foundry.audio.AudioHelper.volumeToInput(game.settings.get("core", "globalInterfaceVolume")),
      interfaceTooltip: PlaylistDirectory.volumeToTooltip(game.settings.get("core", "globalInterfaceVolume")),
      volumeExpanded: this._volumeExpanded,
      currentlyPlaying: {
        class: `location-${currentAtTop ? "top" : "bottom"}`,
        location: {top: currentAtTop, bottom: !currentAtTop},
        pin: {label: `PLAYLIST.PinTo${currentAtTop ? "Bottom" : "Top"}`, caret: currentAtTop ? "down" : "up"}
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Converts a volume level to a human-friendly % value
   * @param {number} volume         Value between [0, 1] of the volume level
   * @returns {string}
   */
  static volumeToTooltip(volume) {
    return game.i18n.format("PLAYLIST.VOLUME.TOOLTIP", { volume: Math.round(foundry.audio.AudioHelper.volumeToInput(volume) * 100) });
  }

  /* -------------------------------------------- */

  /**
   * Augment the tree directory structure with playlist-level data objects for rendering
   * @param {object} node   The tree leaf node being prepared
   * @private
   */
  _prepareTreeData(node) {
    node.entries = node.entries.map(p => this._preparePlaylistData(p));
    for ( const child of node.children ) this._prepareTreeData(child);
  }

  /* -------------------------------------------- */

  /**
   * Create an object of rendering data for each Playlist document being displayed
   * @param {Playlist} playlist   The playlist to display
   * @returns {object}            The data for rendering
   * @private
   */
  _preparePlaylistData(playlist) {
    if ( playlist.playing ) this._playingPlaylists.push(playlist);

    // Playlist configuration
    const p = playlist.toObject(false);
    p.modeTooltip = this._getModeTooltip(p.mode);
    p.modeIcon = this._getModeIcon(p.mode);
    p.disabled = p.mode === CONST.PLAYLIST_MODES.DISABLED;
    p.expanded = this._expanded.has(p._id);
    p.css = [p.expanded ? "" : "collapsed", playlist.playing ? "playing" : ""].filterJoin(" ");
    p.controlCSS = (playlist.isOwner && !p.disabled) ? "" : "disabled";
    p.isOwner = playlist.isOwner;

    // Playlist sounds
    const sounds = [];
    for ( const soundId of playlist.playbackOrder ) {
      const sound = playlist.sounds.get(soundId);
      if ( !(sound.isOwner || sound.playing) ) continue;

      // All sounds
      const s = sound.toObject(false);
      s.playlistId = playlist.id;
      s.css = s.playing ? "playing" : "";
      s.controlCSS = sound.isOwner ? "" : "disabled";
      s.playIcon = this._getPlayIcon(sound);
      s.playTitle = s.pausedTime ? "PLAYLIST.SoundResume" : "PLAYLIST.SoundPlay";
      s.isOwner = sound.isOwner;

      // Playing sounds
      if ( sound.sound && !sound.sound.failed && (sound.playing || s.pausedTime) ) {
        s.isPaused = !sound.playing && s.pausedTime;
        s.pauseIcon = this._getPauseIcon(sound);
        s.lvolume = foundry.audio.AudioHelper.volumeToInput(s.volume);
        s.volumeTooltip = this.constructor.volumeToTooltip(s.volume);
        s.currentTime = this._formatTimestamp(sound.playing ? sound.sound.currentTime : s.pausedTime);
        s.durationTime = this._formatTimestamp(sound.sound.duration);
        this._playingSounds.push(sound);
        this._playingSoundsData.push(s);
      }
      sounds.push(s);
    }
    p.sounds = sounds;
    return p;
  }

  /* -------------------------------------------- */

  /**
   * Get the icon used to represent the "play/stop" icon for the PlaylistSound
   * @param {PlaylistSound} sound   The sound being rendered
   * @returns {string}              The icon that should be used
   * @private
   */
  _getPlayIcon(sound) {
    if ( !sound.playing ) return sound.pausedTime ? "fas fa-play-circle" : "fas fa-play";
    else return "fas fa-square";
  }

  /* -------------------------------------------- */

  /**
   * Get the icon used to represent the pause/loading icon for the PlaylistSound
   * @param {PlaylistSound} sound   The sound being rendered
   * @returns {string}              The icon that should be used
   * @private
   */
  _getPauseIcon(sound) {
    return (sound.playing && !sound.sound?.loaded) ? "fas fa-spinner fa-spin" : "fas fa-pause";
  }

  /* -------------------------------------------- */

  /**
   * Given a constant playback mode, provide the FontAwesome icon used to display it
   * @param {number} mode
   * @returns {string}
   * @private
   */
  _getModeIcon(mode) {
    return {
      [CONST.PLAYLIST_MODES.DISABLED]: "fas fa-ban",
      [CONST.PLAYLIST_MODES.SEQUENTIAL]: "far fa-arrow-alt-circle-right",
      [CONST.PLAYLIST_MODES.SHUFFLE]: "fas fa-random",
      [CONST.PLAYLIST_MODES.SIMULTANEOUS]: "fas fa-compress-arrows-alt"
    }[mode];
  }

  /* -------------------------------------------- */

  /**
   * Given a constant playback mode, provide the string tooltip used to describe it
   * @param {number} mode
   * @returns {string}
   * @private
   */
  _getModeTooltip(mode) {
    return {
      [CONST.PLAYLIST_MODES.DISABLED]: game.i18n.localize("PLAYLIST.ModeDisabled"),
      [CONST.PLAYLIST_MODES.SEQUENTIAL]: game.i18n.localize("PLAYLIST.ModeSequential"),
      [CONST.PLAYLIST_MODES.SHUFFLE]: game.i18n.localize("PLAYLIST.ModeShuffle"),
      [CONST.PLAYLIST_MODES.SIMULTANEOUS]: game.i18n.localize("PLAYLIST.ModeSimultaneous")
    }[mode];
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);

    // Volume sliders
    html.find(".global-volume-slider").change(this._onGlobalVolume.bind(this));
    html.find(".sound-volume").change(this._onSoundVolume.bind(this));

    // Collapse/Expand
    html.find("#global-volume .playlist-header").click(this._onVolumeCollapse.bind(this));

    // Currently playing pinning
    html.find("#currently-playing .pin").click(this._onPlayingPin.bind(this));

    // Playlist Control Events
    html.on("click", "a.sound-control", event => {
      event.preventDefault();
      const btn = event.currentTarget;
      const action = btn.dataset.action;
      if (!action || btn.classList.contains("disabled")) return;

      // Delegate to Playlist and Sound control handlers
      switch (action) {
        case "playlist-mode":
          return this._onPlaylistToggleMode(event);
        case "playlist-play":
        case "playlist-stop":
          return this._onPlaylistPlay(event, action === "playlist-play");
        case "playlist-forward":
        case "playlist-backward":
          return this._onPlaylistSkip(event, action);
        case "sound-create":
          return this._onSoundCreate(event);
        case "sound-pause":
        case "sound-play":
        case "sound-stop":
          return this._onSoundPlay(event, action);
        case "sound-repeat":
          return this._onSoundToggleMode(event);
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle global volume change for the playlist sidebar
   * @param {MouseEvent} event   The initial click event
   * @private
   */
  _onGlobalVolume(event) {
    event.preventDefault();
    const slider = event.currentTarget;
    const volume = foundry.audio.AudioHelper.inputToVolume(slider.value);
    const tooltip = PlaylistDirectory.volumeToTooltip(volume);
    slider.setAttribute("data-tooltip", tooltip);
    game.tooltip.activate(slider, {text: tooltip});
    return game.settings.set("core", slider.name, volume);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  collapseAll() {
    super.collapseAll();
    const el = this.element[0];
    for ( let p of el.querySelectorAll("li.playlist") ) {
      this._collapse(p, true);
    }
    this._expanded.clear();
    this._collapse(el.querySelector("#global-volume"), true);
    this._volumeExpanded = false;
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickEntryName(event) {
    const li = event.currentTarget.closest(".playlist");
    const playlistId = li.dataset.documentId;
    const wasExpanded = this._expanded.has(playlistId);
    this._collapse(li, wasExpanded);
    if ( wasExpanded ) this._expanded.delete(playlistId);
    else this._expanded.add(playlistId);
  }

  /* -------------------------------------------- */

  /**
   * Handle global volume control collapse toggle
   * @param {MouseEvent} event   The initial click event
   * @private
   */
  _onVolumeCollapse(event) {
    event.preventDefault();
    const div = event.currentTarget.parentElement;
    this._volumeExpanded = !this._volumeExpanded;
    this._collapse(div, !this._volumeExpanded);
  }

  /* -------------------------------------------- */

  /**
   * Helper method to render the expansion or collapse of playlists
   * @private
   */
  _collapse(el, collapse, speed = 250) {
    const ol = el.querySelector(".playlist-sounds");
    const icon = el.querySelector("i.collapse");
    if (collapse) { // Collapse the sounds
      $(ol).slideUp(speed, () => {
        el.classList.add("collapsed");
        icon.classList.replace("fa-angle-down", "fa-angle-up");
      });
    }
    else { // Expand the sounds
      $(ol).slideDown(speed, () => {
        el.classList.remove("collapsed");
        icon.classList.replace("fa-angle-up", "fa-angle-down");
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle Playlist playback state changes
   * @param {MouseEvent} event    The initial click event
   * @param {boolean} playing     Is the playlist now playing?
   * @private
   */
  _onPlaylistPlay(event, playing) {
    const li = event.currentTarget.closest(".playlist");
    const playlist = game.playlists.get(li.dataset.documentId);
    if ( playing ) return playlist.playAll();
    else return playlist.stopAll();
  }

  /* -------------------------------------------- */

  /**
   * Handle advancing the playlist to the next (or previous) sound
   * @param {MouseEvent} event    The initial click event
   * @param {string} action       The control action requested
   * @private
   */
  _onPlaylistSkip(event, action) {
    const li = event.currentTarget.closest(".playlist");
    const playlist = game.playlists.get(li.dataset.documentId);
    return playlist.playNext(undefined, {direction: action === "playlist-forward" ? 1 : -1});
  }

  /* -------------------------------------------- */

  /**
   * Handle cycling the playback mode for a Playlist
   * @param {MouseEvent} event   The initial click event
   * @private
   */
  _onPlaylistToggleMode(event) {
    const li = event.currentTarget.closest(".playlist");
    const playlist = game.playlists.get(li.dataset.documentId);
    return playlist.cycleMode();
  }

  /* -------------------------------------------- */

  /**
   * Handle Playlist track addition request
   * @param {MouseEvent} event   The initial click event
   * @private
   */
  _onSoundCreate(event) {
    const li = $(event.currentTarget).parents('.playlist');
    const playlist = game.playlists.get(li.data("documentId"));
    const sound = new PlaylistSound({name: game.i18n.localize("SOUND.New")}, {parent: playlist});
    sound.sheet.render(true, {top: li[0].offsetTop, left: window.innerWidth - 670});
  }

  /* -------------------------------------------- */

  /**
   * Modify the playback state of a Sound within a Playlist
   * @param {MouseEvent} event    The initial click event
   * @param {string} action       The sound control action performed
   * @private
   */
  _onSoundPlay(event, action) {
    const li = event.currentTarget.closest(".sound");
    const playlist = game.playlists.get(li.dataset.playlistId);
    const sound = playlist.sounds.get(li.dataset.soundId);
    switch ( action ) {
      case "sound-play":
        return playlist.playSound(sound);
      case "sound-pause":
        return sound.update({playing: false, pausedTime: sound.sound.currentTime});
      case "sound-stop":
        return playlist.stopSound(sound);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle volume adjustments to sounds within a Playlist
   * @param {Event} event   The initial change event
   * @private
   */
  _onSoundVolume(event) {
    event.preventDefault();
    const slider = event.currentTarget;
    const li = slider.closest(".sound");
    const playlist = game.playlists.get(li.dataset.playlistId);
    const playlistSound = playlist.sounds.get(li.dataset.soundId);

    // Get the desired target volume
    const volume = foundry.audio.AudioHelper.inputToVolume(slider.value);
    if ( volume === playlistSound.volume ) return;

    // Immediately apply a local adjustment
    playlistSound.updateSource({volume});
    playlistSound.sound?.fade(playlistSound.volume, {duration: PlaylistSound.VOLUME_DEBOUNCE_MS});
    const tooltip = PlaylistDirectory.volumeToTooltip(volume);
    slider.setAttribute("data-tooltip", tooltip);
    game.tooltip.activate(slider, {text: tooltip});

    // Debounce a change to the database
    if ( playlistSound.isOwner ) playlistSound.debounceVolume(volume);
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to the sound playback mode
   * @param {Event} event   The initial click event
   * @private
   */
  _onSoundToggleMode(event) {
    event.preventDefault();
    const li = event.currentTarget.closest(".sound");
    const playlist = game.playlists.get(li.dataset.playlistId);
    const sound = playlist.sounds.get(li.dataset.soundId);
    return sound.update({repeat: !sound.repeat});
  }

  /* -------------------------------------------- */

  _onPlayingPin() {
    const location = this._playingLocation === "top" ? "bottom" : "top";
    return game.settings.set("core", "playlist.playingLocation", location);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onSearchFilter(event, query, rgx, html) {
    const isSearch = !!query;
    const playlistIds = new Set();
    const soundIds = new Set();
    const folderIds = new Set();
    const nameOnlySearch = (this.collection.searchMode === CONST.DIRECTORY_SEARCH_MODES.NAME);

    // Match documents and folders
    if ( isSearch ) {

      let results = [];
      if ( !nameOnlySearch ) results = this.collection.search({query: query});

      // Match Playlists and Sounds
      for ( let d of this.documents ) {
        let matched = false;
        for ( let s of d.sounds ) {
          if ( s.playing || rgx.test(SearchFilter.cleanQuery(s.name)) ) {
            soundIds.add(s._id);
            matched = true;
          }
        }
        if ( matched || d.playing || ( nameOnlySearch && rgx.test(SearchFilter.cleanQuery(d.name) )
          || results.some(r => r._id === d._id)) ) {
          playlistIds.add(d._id);
          if ( d.folder ) folderIds.add(d.folder._id);
        }
      }

      // Include parent Folders
      const folders = this.folders.sort((a, b) => b.depth - a.depth);
      for ( let f of folders ) {
        if ( folderIds.has(f.id) && f.folder ) folderIds.add(f.folder._id);
      }
    }

    // Toggle each directory item
    for ( let el of html.querySelectorAll(".directory-item") ) {
      if ( el.classList.contains("global-volume") ) continue;

      // Playlists
      if ( el.classList.contains("document") ) {
        const pid = el.dataset.documentId;
        let playlistIsMatch = !isSearch || playlistIds.has(pid);
        el.style.display = playlistIsMatch ? "flex" : "none";

        // Sounds
        const sounds = el.querySelector(".playlist-sounds");
        for ( const li of sounds.children ) {
          let soundIsMatch = !isSearch || soundIds.has(li.dataset.soundId);
          li.style.display = soundIsMatch ? "flex" : "none";
          if ( soundIsMatch ) {
            playlistIsMatch = true;
          }
        }
        const showExpanded = this._expanded.has(pid) || (isSearch && playlistIsMatch);
        el.classList.toggle("collapsed", !showExpanded);
      }


      // Folders
      else if ( el.classList.contains("folder") ) {
        const hidden = isSearch && !folderIds.has(el.dataset.folderId);
        el.style.display = hidden ? "none" : "flex";
        const uuid = el.closest("li.folder").dataset.uuid;
        const expanded = (isSearch && folderIds.has(el.dataset.folderId)) ||
          (!isSearch && game.folders._expanded[uuid]);
        el.classList.toggle("collapsed", !expanded);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Update the displayed timestamps for all currently playing audio sources.
   * Runs on an interval every 1000ms.
   * @private
   */
  _updateTimestamps() {
    if ( !this._playingSounds.length ) return;
    const playing = this.element.find("#currently-playing")[0];
    if ( !playing ) return;
    for ( let sound of this._playingSounds ) {
      const li = playing.querySelector(`.sound[data-sound-id="${sound.id}"]`);
      if ( !li ) continue;

      // Update current and max playback time
      const current = li.querySelector("span.current");
      const ct = sound.playing ? sound.sound.currentTime : sound.pausedTime;
      if ( current ) current.textContent = this._formatTimestamp(ct);
      const max = li.querySelector("span.duration");
      if ( max ) max.textContent = this._formatTimestamp(sound.sound.duration);

      // Remove the loading spinner
      const play = li.querySelector("a.pause");
      if ( play.classList.contains("fa-spinner") ) {
        play.classList.remove("fa-spin");
        play.classList.replace("fa-spinner", "fa-pause");
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Format the displayed timestamp given a number of seconds as input
   * @param {number} seconds    The current playback time in seconds
   * @returns {string}          The formatted timestamp
   * @private
   */
  _formatTimestamp(seconds) {
    if ( !Number.isFinite(seconds) ) return "∞";
    seconds = seconds ?? 0;
    let minutes = Math.floor(seconds / 60);
    seconds = Math.round(seconds % 60);
    return `${minutes}:${seconds.paddedString(2)}`;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _contextMenu(html) {
    super._contextMenu(html);
    /**
     * A hook event that fires when the context menu for a Sound in the PlaylistDirectory is constructed.
     * @function getPlaylistDirectorySoundContext
     * @memberof hookEvents
     * @param {PlaylistDirectory} application   The Application instance that the context menu is constructed in
     * @param {ContextMenuEntry[]} entryOptions The context menu entries
     */
    ContextMenu.create(this, html, ".playlist .sound", this._getSoundContextOptions(), {hookName: "SoundContext"});
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    options.unshift({
      name: "PLAYLIST.Edit",
      icon: '<i class="fas fa-edit"></i>',
      callback: header => {
        const li = header.closest(".directory-item");
        const playlist = game.playlists.get(li.data("document-id"));
        const sheet = playlist.sheet;
        sheet.render(true, this.popOut ? {} : {
          top: li[0].offsetTop - 24,
          left: window.innerWidth - ui.sidebar.position.width - sheet.options.width - 10
        });
      }
    });
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Get context menu options for individual sound effects
   * @returns {Object}   The context options for each sound
   * @private
   */
  _getSoundContextOptions() {
    return [
      {
        name: "PLAYLIST.SoundEdit",
        icon: '<i class="fas fa-edit"></i>',
        callback: li => {
          const playlistId = li.parents(".playlist").data("document-id");
          const playlist = game.playlists.get(playlistId);
          const sound = playlist.sounds.get(li.data("sound-id"));
          const sheet = sound.sheet;
          sheet.render(true, this.popOut ? {} : {
            top: li[0].offsetTop - 24,
            left: window.innerWidth - ui.sidebar.position.width - sheet.options.width - 10
          });
        }
      },
      {
        name: "PLAYLIST.SoundPreload",
        icon: '<i class="fas fa-download"></i>',
        callback: li => {
          const playlistId = li.parents(".playlist").data("document-id");
          const playlist = game.playlists.get(playlistId);
          const sound = playlist.sounds.get(li.data("sound-id"));
          game.audio.preload(sound.path);
        }
      },
      {
        name: "PLAYLIST.SoundDelete",
        icon: '<i class="fas fa-trash"></i>',
        callback: li => {
          const playlistId = li.parents(".playlist").data("document-id");
          const playlist = game.playlists.get(playlistId);
          const sound = playlist.sounds.get(li.data("sound-id"));
          return sound.deleteDialog({
            top: Math.min(li[0].offsetTop, window.innerHeight - 350),
            left: window.innerWidth - 720
          });
        }
      }
    ];
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _onDragStart(event) {
    const target = event.currentTarget;
    if ( target.classList.contains("sound-name") ) {
      const sound = target.closest(".sound");
      const document = game.playlists.get(sound.dataset.playlistId)?.sounds.get(sound.dataset.soundId);
      event.dataTransfer.setData("text/plain", JSON.stringify(document.toDragData()));
    }
    else super._onDragStart(event);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onDrop(event) {
    const data = TextEditor.getDragEventData(event);
    if ( data.type !== "PlaylistSound" ) return super._onDrop(event);

    // Reference the target playlist and sound elements
    const target = event.target.closest(".sound, .playlist");
    if ( !target ) return false;
    const sound = await PlaylistSound.implementation.fromDropData(data);
    const playlist = sound.parent;
    const otherPlaylistId = target.dataset.documentId || target.dataset.playlistId;

    // Copying to another playlist.
    if ( otherPlaylistId !== playlist.id ) {
      const otherPlaylist = game.playlists.get(otherPlaylistId);
      return PlaylistSound.implementation.create(sound.toObject(), {parent: otherPlaylist});
    }

    // If there's nothing to sort relative to, or the sound was dropped on itself, do nothing.
    const targetId = target.dataset.soundId;
    if ( !targetId || (targetId === sound.id) ) return false;
    sound.sortRelative({
      target: playlist.sounds.get(targetId),
      siblings: playlist.sounds.filter(s => s.id !== sound.id)
    });
  }
}

/**
 * The sidebar directory which organizes and displays world-level RollTable documents.
 * @extends {DocumentDirectory}
 */
class RollTableDirectory extends DocumentDirectory {

  /** @override */
  static documentName = "RollTable";

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getEntryContextOptions() {
    let options = super._getEntryContextOptions();

    // Add the "Roll" option
    options = [
        {
            name: "TABLE.Roll",
            icon: '<i class="fas fa-dice-d20"></i>',
            callback: li => {
                const table = game.tables.get(li.data("documentId"));
                table.draw({roll: true, displayChat: true});
            }
        }
      ].concat(options);
    return options;
  }
}

/**
 * The sidebar directory which organizes and displays world-level Scene documents.
 * @extends {DocumentDirectory}
 */
class SceneDirectory extends DocumentDirectory {

  /** @override */
  static documentName = "Scene";

  /** @override */
  static entryPartial = "templates/sidebar/scene-partial.html";

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.renderUpdateKeys.push("background");
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _render(force, options) {
    if ( !game.user.isGM ) return;
    return super._render(force, options);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getEntryContextOptions() {
    let options = super._getEntryContextOptions();
    options = [
      {
        name: "SCENES.View",
        icon: '<i class="fas fa-eye"></i>',
        condition: li => !canvas.ready || (li.data("documentId") !== canvas.scene.id),
        callback: li => {
          const scene = game.scenes.get(li.data("documentId"));
          scene.view();
        }
      },
      {
        name: "SCENES.Activate",
        icon: '<i class="fas fa-bullseye"></i>',
        condition: li => game.user.isGM && !game.scenes.get(li.data("documentId")).active,
        callback: li => {
          const scene = game.scenes.get(li.data("documentId"));
          scene.activate();
        }
      },
      {
        name: "SCENES.Configure",
        icon: '<i class="fas fa-cogs"></i>',
        callback: li => {
          const scene = game.scenes.get(li.data("documentId"));
          scene.sheet.render(true);
        }
      },
      {
        name: "SCENES.Notes",
        icon: '<i class="fas fa-scroll"></i>',
        condition: li => {
          const scene = game.scenes.get(li.data("documentId"));
          return !!scene.journal;
        },
        callback: li => {
          const scene = game.scenes.get(li.data("documentId"));
          const entry = scene.journal;
          if ( entry ) {
            const sheet = entry.sheet;
            const options = {};
            if ( scene.journalEntryPage ) options.pageId = scene.journalEntryPage;
            sheet.render(true, options);
          }
        }
      },
      {
        name: "SCENES.ToggleNav",
        icon: '<i class="fas fa-compass"></i>',
        condition: li => {
          const scene = game.scenes.get(li.data("documentId"));
          return game.user.isGM && ( !scene.active );
        },
        callback: li => {
          const scene = game.scenes.get(li.data("documentId"));
          scene.update({navigation: !scene.navigation});
        }
      },
      {
        name: "SCENES.GenerateThumb",
        icon: '<i class="fas fa-image"></i>',
        condition: li => {
          const scene = game.scenes.get(li[0].dataset.documentId);
          return (scene.background.src || scene.tiles.size) && !game.settings.get("core", "noCanvas");
        },
        callback: li => {
          const scene = game.scenes.get(li[0].dataset.documentId);
          scene.createThumbnail().then(data => {
            scene.update({thumb: data.thumb}, {diff: false});
            ui.notifications.info(game.i18n.format("SCENES.GenerateThumbSuccess", {name: scene.name}));
          }).catch(err => ui.notifications.error(err.message));
        }
      }
    ].concat(options);

    // Remove the ownership entry
    options.findSplice(o => o.name === "OWNERSHIP.Configure");
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _getFolderContextOptions() {
    const options = super._getFolderContextOptions();
    options.findSplice(o => o.name === "OWNERSHIP.Configure");
    return options;
  }
}

/**
 * The sidebar tab which displays various game settings, help messages, and configuration options.
 * The Settings sidebar is the furthest-to-right using a triple-cogs icon.
 * @extends {SidebarTab}
 */
class Settings extends SidebarTab {

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "settings",
      template: "templates/sidebar/settings.html",
      title: "Settings"
    });
  }

  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    const context = await super.getData(options);

    // Check for core update
    let coreUpdate;
    if ( game.user.isGM && game.data.coreUpdate.hasUpdate ) {
      coreUpdate = game.i18n.format("SETUP.UpdateAvailable", {
        type: game.i18n.localize("Software"),
        channel: game.data.coreUpdate.channel,
        version: game.data.coreUpdate.version
      });
    }

    // Check for system update
    let systemUpdate;
    if ( game.user.isGM && game.data.systemUpdate.hasUpdate ) {
      systemUpdate = game.i18n.format("SETUP.UpdateAvailable", {
        type: game.i18n.localize("System"),
        channel: game.data.system.title,
        version: game.data.systemUpdate.version
      });
    }

    const issues = CONST.WORLD_DOCUMENT_TYPES.reduce((count, documentName) => {
      const collection = CONFIG[documentName].collection.instance;
      return count + collection.invalidDocumentIds.size;
    }, 0) + Object.values(game.issues.packageCompatibilityIssues).reduce((count, {error}) => {
      return count + error.length;
    }, 0) + Object.keys(game.issues.usabilityIssues).length;

    // Return rendering context
    const isDemo = game.data.demoMode;
    return foundry.utils.mergeObject(context, {
      system: game.system,
      release: game.data.release,
      versionDisplay: game.release.display,
      canConfigure: game.user.can("SETTINGS_MODIFY") && !isDemo,
      canEditWorld: game.user.hasRole("GAMEMASTER") && !isDemo,
      canManagePlayers: game.user.isGM && !isDemo,
      canReturnSetup: game.user.hasRole("GAMEMASTER") && !isDemo,
      modules: game.modules.reduce((n, m) => n + (m.active ? 1 : 0), 0),
      issues,
      isDemo,
      coreUpdate,
      systemUpdate
    });
  }

  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    html.find("button[data-action]").click(this._onSettingsButton.bind(this));
    html.find(".notification-pip.update").click(this._onUpdateNotificationClick.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Delegate different actions for different settings buttons
   * @param {MouseEvent} event    The originating click event
   * @private
   */
  _onSettingsButton(event) {
    event.preventDefault();
    const button = event.currentTarget;
    switch (button.dataset.action) {
      case "configure":
        game.settings.sheet.render(true);
        break;
      case "modules":
        new ModuleManagement().render(true);
        break;
      case "world":
        new WorldConfig(game.world).render(true);
        break;
      case "players":
        return ui.menu.items.players.onClick();
      case "setup":
        return game.shutDown();
      case "support":
        new SupportDetails().render(true);
        break;
      case "controls":
        new KeybindingsConfig().render(true);
        break;
      case "tours":
        new ToursManagement().render(true);
        break;
      case "docs":
        new FrameViewer("https://foundryvtt.com/kb", {
          title: "SIDEBAR.Documentation"
        }).render(true);
        break;
      case "wiki":
        new FrameViewer("https://foundryvtt.wiki/", {
          title: "SIDEBAR.Wiki"
        }).render(true);
        break;
      case "invitations":
        new InvitationLinks().render(true);
        break;
      case "logout":
        return ui.menu.items.logout.onClick();
    }
  }

  /* -------------------------------------------- */

  /**
   * Executes with the update notification pip is clicked
   * @param {MouseEvent} event    The originating click event
   * @private
   */
  _onUpdateNotificationClick(event) {
    event.preventDefault();
    const key = event.target.dataset.action === "core-update" ? "CoreUpdateInstructions" : "SystemUpdateInstructions";
    ui.notifications.notify(game.i18n.localize(`SETUP.${key}`));
  }
}

/* -------------------------------------------- */

/**
 * A simple window application which shows the built documentation pages within an iframe
 * @type {Application}
 */
class FrameViewer extends Application {
  constructor(url, options) {
    super(options);
    this.url = url;
  }

  /* -------------------------------------------- */

  /** @override */
  static get defaultOptions() {
    const options = super.defaultOptions;
    const h = window.innerHeight * 0.9;
    const w = Math.min(window.innerWidth * 0.9, 1200);
    options.height = h;
    options.width = w;
    options.top = (window.innerHeight - h) / 2;
    options.left = (window.innerWidth - w) / 2;
    options.id = "documentation";
    options.template = "templates/apps/documentation.html";
    return options;
  }

  /* -------------------------------------------- */

  /** @override */
  async getData(options={}) {
    return {
      src: this.url
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async close(options) {
    this.element.find("#docs").remove();
    return super.close(options);
  }
}

/**
 * An interface for an Audio/Video client which is extended to provide broadcasting functionality.
 * @interface
 * @param {AVMaster} master           The master orchestration instance
 * @param {AVSettings} settings       The audio/video settings being used
 */
class AVClient {
  constructor(master, settings) {

    /**
     * The master orchestration instance
     * @type {AVMaster}
     */
    this.master = master;

    /**
     * The active audio/video settings being used
     * @type {AVSettings}
     */
    this.settings = settings;
  }

  /* -------------------------------------------- */

  /**
   * Is audio broadcasting push-to-talk enabled?
   * @returns {boolean}
   */
  get isVoicePTT() {
    return this.settings.client.voice.mode === "ptt";
  }

  /**
   * Is audio broadcasting always enabled?
   * @returns {boolean}
   */
  get isVoiceAlways() {
    return this.settings.client.voice.mode === "always";
  }

  /**
   * Is audio broadcasting voice-activation enabled?
   * @returns {boolean}
   */
  get isVoiceActivated() {
    return this.settings.client.voice.mode === "activity";
  }

  /**
   * Is the current user muted?
   * @returns {boolean}
   */
  get isMuted() {
    return this.settings.client.users[game.user.id]?.muted;
  }

  /* -------------------------------------------- */
  /*  Connection                                  */
  /* -------------------------------------------- */

  /**
   * One-time initialization actions that should be performed for this client implementation.
   * This will be called only once when the Game object is first set-up.
   * @returns {Promise<void>}
   */
  async initialize() {
    throw Error("The initialize() method must be defined by an AVClient subclass.");
  }

  /* -------------------------------------------- */

  /**
   * Connect to any servers or services needed in order to provide audio/video functionality.
   * Any parameters needed in order to establish the connection should be drawn from the settings object.
   * This function should return a boolean for whether the connection attempt was successful.
   * @returns {Promise<boolean>}   Was the connection attempt successful?
   */
  async connect() {
    throw Error("The connect() method must be defined by an AVClient subclass.");
  }

  /* -------------------------------------------- */

  /**
   * Disconnect from any servers or services which are used to provide audio/video functionality.
   * This function should return a boolean for whether a valid disconnection occurred.
   * @returns {Promise<boolean>}   Did a disconnection occur?
   */
  async disconnect() {
    throw Error("The disconnect() method must be defined by an AVClient subclass.");
  }

  /* -------------------------------------------- */
  /*  Device Discovery                            */
  /* -------------------------------------------- */

  /**
   * Provide an Object of available audio sources which can be used by this implementation.
   * Each object key should be a device id and the key should be a human-readable label.
   * @returns {Promise<{object}>}
   */
  async getAudioSinks() {
    return this._getSourcesOfType("audiooutput");
  }

  /* -------------------------------------------- */

  /**
   * Provide an Object of available audio sources which can be used by this implementation.
   * Each object key should be a device id and the key should be a human-readable label.
   * @returns {Promise<{object}>}
   */
  async getAudioSources() {
    return this._getSourcesOfType("audioinput");
  }

  /* -------------------------------------------- */

  /**
   * Provide an Object of available video sources which can be used by this implementation.
   * Each object key should be a device id and the key should be a human-readable label.
   * @returns {Promise<{object}>}
   */
  async getVideoSources() {
    return this._getSourcesOfType("videoinput");
  }

  /* -------------------------------------------- */

  /**
   * Obtain a mapping of available device sources for a given type.
   * @param {string} kind       The type of device source being requested
   * @returns {Promise<{object}>}
   * @private
   */
  async _getSourcesOfType(kind) {
    if ( !("mediaDevices" in navigator) ) return {};
    const devices = await navigator.mediaDevices.enumerateDevices();
    return devices.reduce((obj, device) => {
      if ( device.kind === kind ) {
        obj[device.deviceId] = device.label || game.i18n.localize("WEBRTC.UnknownDevice");
      }
      return obj;
    }, {});
  }

  /* -------------------------------------------- */
  /*  Track Manipulation                          */
  /* -------------------------------------------- */

  /**
   * Return an array of Foundry User IDs which are currently connected to A/V.
   * The current user should also be included as a connected user in addition to all peers.
   * @returns {string[]}          The connected User IDs
   */
  getConnectedUsers() {
    throw Error("The getConnectedUsers() method must be defined by an AVClient subclass.");
  }

  /* -------------------------------------------- */

  /**
   * Provide a MediaStream instance for a given user ID
   * @param {string} userId        The User id
   * @returns {MediaStream|null}   The MediaStream for the user, or null if the user does not have one
   */
  getMediaStreamForUser(userId) {
    throw Error("The getMediaStreamForUser() method must be defined by an AVClient subclass.");
  }

  /* -------------------------------------------- */

  /**
   * Provide a MediaStream for monitoring a given user's voice volume levels.
   * @param {string} userId       The User ID.
   * @returns {MediaStream|null}  The MediaStream for the user, or null if the user does not have one.
   */
  getLevelsStreamForUser(userId) {
    throw new Error("An AVClient subclass must define the getLevelsStreamForUser method");
  }

  /* -------------------------------------------- */

  /**
   * Is outbound audio enabled for the current user?
   * @returns {boolean}
   */
  isAudioEnabled() {
    throw Error("The isAudioEnabled() method must be defined by an AVClient subclass.");
  }

  /* -------------------------------------------- */

  /**
   * Is outbound video enabled for the current user?
   * @returns {boolean}
   */
  isVideoEnabled() {
    throw Error("The isVideoEnabled() method must be defined by an AVClient subclass.");
  }

  /* -------------------------------------------- */

  /**
   * Set whether the outbound audio feed for the current game user is enabled.
   * This method should be used when the user marks themselves as muted or if the gamemaster globally mutes them.
   * @param {boolean} enable        Whether the outbound audio track should be enabled (true) or disabled (false)
   */
  toggleAudio(enable) {
    throw Error("The toggleAudio() method must be defined by an AVClient subclass.");
  }

  /* -------------------------------------------- */

  /**
   * Set whether the outbound audio feed for the current game user is actively broadcasting.
   * This can only be true if audio is enabled, but may be false if using push-to-talk or voice activation modes.
   * @param {boolean} broadcast      Whether outbound audio should be sent to connected peers or not?
   */
  toggleBroadcast(broadcast) {
    throw Error("The toggleBroadcast() method must be defined by an AVClient subclass.");
  }

  /* -------------------------------------------- */

  /**
   * Set whether the outbound video feed for the current game user is enabled.
   * This method should be used when the user marks themselves as hidden or if the gamemaster globally hides them.
   * @param {boolean} enable        Whether the outbound video track should be enabled (true) or disabled (false)
   */
  toggleVideo(enable) {
    throw Error("The toggleVideo() method must be defined by an AVClient subclass.");
  }

  /* -------------------------------------------- */

  /**
   * Set the Video Track for a given User ID to a provided VideoElement
   * @param {string} userId                   The User ID to set to the element
   * @param {HTMLVideoElement} videoElement   The HTMLVideoElement to which the video should be set
   */
  async setUserVideo(userId, videoElement) {
    throw Error("The setUserVideo() method must be defined by an AVClient subclass.");
  }

  /* -------------------------------------------- */
  /*  Settings and Configuration                  */
  /* -------------------------------------------- */

  /**
   * Handle changes to A/V configuration settings.
   * @param {object} changed      The settings which have changed
   */
  onSettingsChanged(changed) {}

  /* -------------------------------------------- */

  /**
   * Replace the local stream for each connected peer with a re-generated MediaStream.
   */
  async updateLocalStream() {
    throw Error("The updateLocalStream() method must be defined by an AVClient subclass.");
  }
}

/**
 * The master Audio/Video controller instance.
 * This is available as the singleton game.webrtc
 *
 * @param {AVSettings} settings     The Audio/Video settings to use
 */
class AVMaster {
  constructor() {
    this.settings = new AVSettings();
    this.config = new AVConfig(this);

    /**
     * The Audio/Video client class
     * @type {AVClient}
     */
    this.client = new CONFIG.WebRTC.clientClass(this, this.settings);

    /**
     * A flag to track whether the current user is actively broadcasting their microphone.
     * @type {boolean}
     */
    this.broadcasting = false;

    /**
     * Flag to determine if we are connected to the signalling server or not.
     * This is required for synchronization between connection and reconnection attempts.
     * @type {boolean}
     */
    this._connected = false;

    /**
     * The cached connection promise.
     * This is required to prevent re-triggering a connection while one is already in progress.
     * @type {Promise<boolean>|null}
     * @private
     */
    this._connecting = null;

    /**
     * A flag to track whether the A/V system is currently in the process of reconnecting.
     * This occurs if the connection is lost or interrupted.
     * @type {boolean}
     * @private
     */
    this._reconnecting = false;

    // Other internal flags
    this._speakingData = {speaking: false, volumeHistories: []};
    this._pttMuteTimeout = 0;
  }

  /* -------------------------------------------- */

  get mode() {
    return this.settings.world.mode;
  }

  /* -------------------------------------------- */
  /*  Initialization                              */
  /* -------------------------------------------- */

  /**
   * Connect to the Audio/Video client.
   * @return {Promise<boolean>}     Was the connection attempt successful?
   */
  async connect() {
    if ( this._connecting ) return this._connecting;
    const connect = async () => {
      // Disconnect from any existing session
      await this.disconnect();

      // Activate the connection
      if ( this.mode === AVSettings.AV_MODES.DISABLED ) return false;

      // Initialize Client state
      await this.client.initialize();

      // Connect to the client
      const connected = await this.client.connect();
      if ( !connected ) return false;
      console.log(`${vtt} | Connected to the ${this.client.constructor.name} Audio/Video client.`);

      // Initialize local broadcasting
      this._initialize();
      return this._connected = connected;
    };

    return this._connecting = connect().finally(() => this._connecting = null);
  }

  /* -------------------------------------------- */

  /**
   * Disconnect from the Audio/Video client.
   * @return {Promise<boolean>}     Whether an existing connection was terminated?
   */
  async disconnect() {
    if ( !this._connected ) return false;
    this._connected = this._reconnecting = false;
    await this.client.disconnect();
    console.log(`${vtt} | Disconnected from the ${this.client.constructor.name} Audio/Video client.`);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Callback actions to take when the user becomes disconnected from the server.
   * @return {Promise<void>}
   */
  async reestablish() {
    if ( !this._connected ) return;
    ui.notifications.warn("WEBRTC.ConnectionLostWarning", {localize: true});
    await this.disconnect();

    // Attempt to reconnect
    while ( this._reconnecting ) {
      await this.connect();
      if ( this._connected ) {
        this._reconnecting = true;
        break;
      }
      await new Promise(resolve => setTimeout(resolve, this._reconnectPeriodMS));
    }
  }

  /* -------------------------------------------- */

  /**
   * Initialize the local broadcast state.
   * @private
   */
  _initialize() {
    const client = this.settings.client;
    const voiceMode = client.voice.mode;

    // Initialize voice detection
    this._initializeUserVoiceDetection(voiceMode);

    // Reset the speaking history for the user
    this._resetSpeakingHistory(game.user.id);

    // Set the initial state of outbound audio and video streams
    const isAlways = voiceMode === "always";
    this.client.toggleAudio(isAlways && client.audioSrc && this.canUserShareAudio(game.user.id));
    this.client.toggleVideo(client.videoSrc && this.canUserShareVideo(game.user.id));
    this.broadcast(isAlways);

    // Update the display of connected A/V
    ui.webrtc.render();
  }

  /* -------------------------------------------- */
  /*  Permissions                                 */
  /* -------------------------------------------- */

  /**
   * A user can broadcast audio if the AV mode is compatible and if they are allowed to broadcast.
   * @param {string} userId
   * @return {boolean}
   */
  canUserBroadcastAudio(userId) {
    if ( [AVSettings.AV_MODES.DISABLED, AVSettings.AV_MODES.VIDEO].includes(this.mode) ) return false;
    const user = this.settings.getUser(userId);
    return user && user.canBroadcastAudio;
  }

  /* -------------------------------------------- */

  /**
   * A user can share audio if they are allowed to broadcast and if they have not muted themselves or been blocked.
   * @param {string} userId
   * @return {boolean}
   */
  canUserShareAudio(userId) {
    if ( [AVSettings.AV_MODES.DISABLED, AVSettings.AV_MODES.VIDEO].includes(this.mode) ) return false;
    const user = this.settings.getUser(userId);
    return user && user.canBroadcastAudio && !(user.muted || user.blocked);
  }

  /* -------------------------------------------- */

  /**
   * A user can broadcast video if the AV mode is compatible and if they are allowed to broadcast.
   * @param {string} userId
   * @return {boolean}
   */
  canUserBroadcastVideo(userId) {
    if ( [AVSettings.AV_MODES.DISABLED, AVSettings.AV_MODES.AUDIO].includes(this.mode) ) return false;
    const user = this.settings.getUser(userId);
    return user && user.canBroadcastVideo;
  }

  /* -------------------------------------------- */

  /**
   * A user can share video if they are allowed to broadcast and if they have not hidden themselves or been blocked.
   * @param {string} userId
   * @return {boolean}
   */
  canUserShareVideo(userId) {
    if ( [AVSettings.AV_MODES.DISABLED, AVSettings.AV_MODES.AUDIO].includes(this.mode) ) return false;
    const user = this.settings.getUser(userId);
    return user && user.canBroadcastVideo && !(user.hidden || user.blocked);
  }

  /* -------------------------------------------- */
  /*  Broadcasting                                */
  /* -------------------------------------------- */

  /**
   * Trigger a change in the audio broadcasting state when using a push-to-talk workflow.
   * @param {boolean} intent        The user's intent to broadcast. Whether an actual broadcast occurs will depend
   *                                on whether or not the user has muted their audio feed.
   */
  broadcast(intent) {
    this.broadcasting = intent && this.canUserShareAudio(game.user.id);
    this.client.toggleBroadcast(this.broadcasting);
    const activity = this.settings.activity[game.user.id];
    if ( activity.speaking !== this.broadcasting ) game.user.broadcastActivity({av: {speaking: this.broadcasting}});
    activity.speaking = this.broadcasting;
    return ui.webrtc.setUserIsSpeaking(game.user.id, this.broadcasting);
  }

  /* -------------------------------------------- */

  /**
   * Set up audio level listeners to handle voice activation detection workflow.
   * @param {string} mode           The currently selected voice broadcasting mode
   * @private
   */
  _initializeUserVoiceDetection(mode) {

    // Deactivate prior detection
    game.audio.stopLevelReports(game.user.id);
    if ( !["always", "activity"].includes(mode) ) return;

    // Activate voice level detection for always-on and activity-based broadcasting
    const stream = this.client.getLevelsStreamForUser(game.user.id);
    const ms = mode === "activity" ? CONFIG.WebRTC.detectSelfVolumeInterval : CONFIG.WebRTC.detectPeerVolumeInterval;
    this.activateVoiceDetection(stream, ms);
  }

  /* -------------------------------------------- */

  /**
   * Activate voice detection tracking for a userId on a provided MediaStream.
   * Currently only a MediaStream is supported because MediaStreamTrack processing is not yet supported cross-browser.
   * @param {MediaStream} stream    The MediaStream which corresponds to that User
   * @param {number} [ms]           A number of milliseconds which represents the voice activation volume interval
   */
  activateVoiceDetection(stream, ms) {
    this.deactivateVoiceDetection();
    if ( !stream || !stream.getAudioTracks().some(t => t.enabled) ) return;
    ms = ms || CONFIG.WebRTC.detectPeerVolumeInterval;
    const handler = this._onAudioLevel.bind(this);
    game.audio.startLevelReports(game.userId, stream, handler, ms);
  }

  /* -------------------------------------------- */

  /**
   * Actions which the orchestration layer should take when a peer user disconnects from the audio/video service.
   */
  deactivateVoiceDetection() {
    this._resetSpeakingHistory();
    game.audio.stopLevelReports(game.userId);
  }

  /* -------------------------------------------- */

  /**
   * Periodic notification of user audio level
   *
   * This function uses the audio level (in dB) of the audio stream to determine if the user is speaking or not and
   * notifies the UI of such changes.
   *
   * The User is considered speaking if they are above the decibel threshold in any of the history values.
   * This marks them as speaking as soon as they have a high enough volume, and marks them as not speaking only after
   * they drop below the threshold in all histories (last 4 volumes = for 200 ms).
   *
   * There can be more optimal ways to do this and which uses whether the user was already considered speaking before
   * or not, in order to eliminate short bursts of audio (coughing for example).
   *
   * @param {number} dbLevel         The audio level in decibels of the user within the last 50ms
   * @private
   */
  _onAudioLevel(dbLevel) {
    const voice = this.settings.client.voice;
    const speakingData = this._speakingData;
    const wasSpeaking = speakingData.speaking;

    // Add the current volume to the history of the user and keep the list below the history length config.
    if (speakingData.volumeHistories.push(dbLevel) > CONFIG.WebRTC.speakingHistoryLength) {
      speakingData.volumeHistories.shift();
    }

    // Count the number and total decibels of speaking events which exceed an activity threshold
    const [count, max, total] = speakingData.volumeHistories.reduce((totals, vol) => {
      if ( vol >= voice.activityThreshold )  {
        totals[0] += 1;
        totals[1] = Math.min(totals[1], vol);
        totals[2] += vol;
      }
      return totals;
    }, [0, 0, 0]);

    // The user is classified as currently speaking if they exceed a certain threshold of speaking events
    const isSpeaking = (count > (wasSpeaking ? 0 : CONFIG.WebRTC.speakingThresholdEvents)) && !this.client.isMuted;
    speakingData.speaking = isSpeaking;

    // Take further action when a change in the speaking state has occurred
    if ( isSpeaking === wasSpeaking ) return;
    if ( this.client.isVoiceActivated ) return this.broadcast(isSpeaking); // Declare broadcast intent
  }

  /* -------------------------------------------- */
  /*  Push-To-Talk Controls                       */
  /* -------------------------------------------- */

  /**
   * Resets the speaking history of a user
   * If the user was considered speaking, then mark them as not speaking
   */
  _resetSpeakingHistory() {
    if ( ui.webrtc ) ui.webrtc.setUserIsSpeaking(game.userId, false);
    this._speakingData.speaking = false;
    this._speakingData.volumeHistories = [];
  }

  /* -------------------------------------------- */

  /**
   * Handle activation of a push-to-talk key or button.
   * @param {KeyboardEventContext} context    The context data of the event
   */
  _onPTTStart(context) {
    if ( !this._connected ) return false;
    const voice = this.settings.client.voice;

    // Case 1: Push-to-Talk (begin broadcasting immediately)
    if ( voice.mode === "ptt" ) {
      if (this._pttMuteTimeout > 0) clearTimeout(this._pttMuteTimeout);
      this._pttMuteTimeout = 0;
      this.broadcast(true);
    }

    // Case 2: Push-to-Mute (disable broadcasting on a timeout)
    else this._pttMuteTimeout = setTimeout(() => this.broadcast(false), voice.pttDelay);

    return true;
  }

  /* -------------------------------------------- */

  /**
   * Handle deactivation of a push-to-talk key or button.
   * @param {KeyboardEventContext} context    The context data of the event
   */
  _onPTTEnd(context) {
    if ( !this._connected ) return false;
    const voice = this.settings.client.voice;

    // Case 1: Push-to-Talk (disable broadcasting on a timeout)
    if ( voice.mode === "ptt" ) {
      this._pttMuteTimeout = setTimeout(() => this.broadcast(false), voice.pttDelay);
    }

    // Case 2: Push-to-Mute (re-enable broadcasting immediately)
    else {
      if (this._pttMuteTimeout > 0) clearTimeout(this._pttMuteTimeout);
      this._pttMuteTimeout = 0;
      this.broadcast(true);
    }
    return true;
  }

  /* -------------------------------------------- */
  /*  User Interface Controls                     */
  /* -------------------------------------------- */

  render() {
    return ui.webrtc.render();
  }

  /* -------------------------------------------- */

  /**
   * Render the audio/video streams to the CameraViews UI.
   * Assign each connected user to the correct video frame element.
   */
  onRender() {
    const users = this.client.getConnectedUsers();
    for ( let u of users ) {
      const videoElement = ui.webrtc.getUserVideoElement(u);
      if ( !videoElement ) continue;
      const isSpeaking = this.settings.activity[u]?.speaking || false;
      this.client.setUserVideo(u, videoElement);
      ui.webrtc.setUserIsSpeaking(u, isSpeaking);
    }

    // Determine the players list position based on the user's settings.
    const dockPositions = AVSettings.DOCK_POSITIONS;
    const isAfter = [dockPositions.RIGHT, dockPositions.BOTTOM].includes(this.settings.client.dockPosition);
    const iface = document.getElementById("interface");
    const cameraViews = ui.webrtc.element[0];
    ui.players.render(true);

    if ( this.settings.client.hideDock || ui.webrtc.hidden ) {
      cameraViews?.style.removeProperty("width");
      cameraViews?.style.removeProperty("height");
    }

    document.body.classList.toggle("av-horizontal-dock", !this.settings.verticalDock);

    // Change the dock position based on the user's settings.
    if ( cameraViews ) {
      if ( isAfter && (iface.nextElementSibling !== cameraViews) ) document.body.insertBefore(iface, cameraViews);
      else if ( !isAfter && (cameraViews.nextElementSibling !== iface) ) document.body.insertBefore(cameraViews, iface);
    }
  }

  /* -------------------------------------------- */
  /*  Events Handlers and Callbacks               */
  /* -------------------------------------------- */

  /**
   * Respond to changes which occur to AV Settings.
   * Changes are handled in descending order of impact.
   * @param {object} changed       The object of changed AV settings
   */
  onSettingsChanged(changed) {
    const keys = Object.keys(foundry.utils.flattenObject(changed));

    // Change the server configuration (full AV re-connection)
    if ( keys.includes("world.turn") ) return this.connect();

    // Change audio and video visibility at a user level
    const sharing = foundry.utils.getProperty(changed, `client.users.${game.userId}`) || {};
    if ( "hidden" in sharing ) this.client.toggleVideo(this.canUserShareVideo(game.userId));
    if ( "muted" in sharing ) this.client.toggleAudio(this.canUserShareAudio(game.userId));

    // Restore stored dock width when switching to a vertical dock position.
    const isVertical =
      [AVSettings.DOCK_POSITIONS.LEFT, AVSettings.DOCK_POSITIONS.RIGHT].includes(changed.client?.dockPosition);
    const dockWidth = changed.client?.dockWidth ?? this.settings.client.dockWidth ?? 240;
    if ( isVertical ) ui.webrtc.position.width = dockWidth;

    // Switch resize direction if docked to the right.
    if ( keys.includes("client.dockPosition") ) {
      ui.webrtc.options.resizable.rtl = changed.client.dockPosition === AVSettings.DOCK_POSITIONS.RIGHT;
    }

    // Requires re-render.
    const rerender = ["client.borderColors", "client.dockPosition", "client.nameplates"].some(k => keys.includes(k));
    if ( rerender ) ui.webrtc.render(true);

    // Call client specific setting handling
    this.client.onSettingsChanged(changed);
  }

  /* -------------------------------------------- */

  debug(message) {
    if ( this.settings.debug ) console.debug(message);
  }
}

/**
 * @typedef {object} AVSettingsData
 * @property {boolean} [muted]     Whether this user has muted themselves.
 * @property {boolean} [hidden]    Whether this user has hidden their video.
 * @property {boolean} [speaking]  Whether the user is broadcasting audio.
 */

class AVSettings {
  constructor() {
    this.initialize();
    this._set = foundry.utils.debounce((key, value) => game.settings.set("core", key, value), 100);
    this._change = foundry.utils.debounce(this._onSettingsChanged.bind(this), 100);
    this.activity[game.userId] = {};
  }

  /* -------------------------------------------- */

  /**
   * WebRTC Mode, Disabled, Audio only, Video only, Audio & Video
   * @enum {number}
   */
  static AV_MODES = {
    DISABLED: 0,
    AUDIO: 1,
    VIDEO: 2,
    AUDIO_VIDEO: 3
  };

  /* -------------------------------------------- */

  /**
   * Voice modes: Always-broadcasting, voice-level triggered, push-to-talk.
   * @enum {string}
   */
  static VOICE_MODES = {
    ALWAYS: "always",
    ACTIVITY: "activity",
    PTT: "ptt"
  };

  /* -------------------------------------------- */

  /**
   * Displayed nameplate options: Off entirely, animate between player and character name, player name only, character
   * name only.
   * @enum {number}
   */
  static NAMEPLATE_MODES = {
    OFF: 0,
    BOTH: 1,
    PLAYER_ONLY: 2,
    CHAR_ONLY: 3
  };

  /* -------------------------------------------- */

  /**
   * AV dock positions.
   * @enum {string}
   */
  static DOCK_POSITIONS = {
    TOP: "top",
    RIGHT: "right",
    BOTTOM: "bottom",
    LEFT: "left"
  };

  /* -------------------------------------------- */

  /**
   * Default client AV settings.
   * @type {object}
   */
  static DEFAULT_CLIENT_SETTINGS = {
    videoSrc: "default",
    audioSrc: "default",
    audioSink: "default",
    dockPosition: AVSettings.DOCK_POSITIONS.LEFT,
    hidePlayerList: false,
    hideDock: false,
    muteAll: false,
    disableVideo: false,
    borderColors: false,
    dockWidth: 240,
    nameplates: AVSettings.NAMEPLATE_MODES.BOTH,
    voice: {
      mode: AVSettings.VOICE_MODES.PTT,
      pttName: "`",
      pttDelay: 100,
      activityThreshold: -45
    },
    users: {}
  };

  /* -------------------------------------------- */

  /**
   * Default world-level AV settings.
   * @type {object}
   */
  static DEFAULT_WORLD_SETTINGS = {
    mode: AVSettings.AV_MODES.DISABLED,
    turn: {
      type: "server",
      url: "",
      username: "",
      password: ""
    }
  };

  /* -------------------------------------------- */

  /**
   * Default client settings for each connected user.
   * @type {object}
   */
  static DEFAULT_USER_SETTINGS = {
    popout: false,
    x: 100,
    y: 100,
    z: 0,
    width: 320,
    volume: 1.0,
    muted: false,
    hidden: false,
    blocked: false
  };

  /* -------------------------------------------- */

  /**
   * Stores the transient AV activity data received from other users.
   * @type {Record<string, AVSettingsData>}
   */
  activity = {};

  /* -------------------------------------------- */

  initialize() {
    this.client = game.settings.get("core", "rtcClientSettings");
    this.world = game.settings.get("core", "rtcWorldSettings");
    this._original = foundry.utils.deepClone({client: this.client, world: this.world});
    const {muted, hidden} = this._getUserSettings(game.user);
    game.user.broadcastActivity({av: {muted, hidden}});
  }

  /* -------------------------------------------- */

  changed() {
    return this._change();
  }

  /* -------------------------------------------- */

  get(scope, setting) {
    return foundry.utils.getProperty(this[scope], setting);
  }

  /* -------------------------------------------- */

  getUser(userId) {
    const user = game.users.get(userId);
    if ( !user ) return null;
    return this._getUserSettings(user);
  }

  /* -------------------------------------------- */

  set(scope, setting, value) {
    foundry.utils.setProperty(this[scope], setting, value);
    this._set(`rtc${scope.titleCase()}Settings`, this[scope]);
  }

  /* -------------------------------------------- */

  /**
   * Return a mapping of AV settings for each game User.
   * @type {object}
   */
  get users() {
    const users = {};
    for ( let u of game.users ) {
      users[u.id] = this._getUserSettings(u);
    }
    return users;
  }

  /* -------------------------------------------- */

  /**
   * A helper to determine if the dock is configured in a vertical position.
   */
  get verticalDock() {
    const positions = this.constructor.DOCK_POSITIONS;
    return [positions.LEFT, positions.RIGHT].includes(this.client.dockPosition ?? positions.LEFT);
  }

  /* -------------------------------------------- */

  /**
   * Prepare a standardized object of user settings data for a single User
   * @private
   */
  _getUserSettings(user) {
    const clientSettings = this.client.users[user.id] || {};
    const activity = this.activity[user.id] || {};
    const settings = foundry.utils.mergeObject(AVSettings.DEFAULT_USER_SETTINGS, clientSettings, {inplace: false});
    settings.canBroadcastAudio = user.can("BROADCAST_AUDIO");
    settings.canBroadcastVideo = user.can("BROADCAST_VIDEO");

    if ( user.isSelf ) {
      settings.muted ||= !game.webrtc?.client.isAudioEnabled();
      settings.hidden ||= !game.webrtc?.client.isVideoEnabled();
    } else {
      // Either we have muted or hidden them, or they have muted or hidden themselves.
      settings.muted ||= !!activity.muted;
      settings.hidden ||= !!activity.hidden;
    }

    settings.speaking = activity.speaking;
    return settings;
  }

  /* -------------------------------------------- */

  /**
   * Handle setting changes to either rctClientSettings or rtcWorldSettings.
   * @private
   */
  _onSettingsChanged() {
    const original = this._original;
    this.initialize();
    const changed = foundry.utils.diffObject(original, this._original);
    game.webrtc.onSettingsChanged(changed);
    Hooks.callAll("rtcSettingsChanged", this, changed);
  }

  /* -------------------------------------------- */

  /**
   * Handle another connected user changing their AV settings.
   * @param {string} userId
   * @param {AVSettingsData} settings
   */
  handleUserActivity(userId, settings) {
    const current = this.activity[userId] || {};
    this.activity[userId] = foundry.utils.mergeObject(current, settings, {inplace: false});
    if ( !ui.webrtc ) return;
    const hiddenChanged = ("hidden" in settings) && (current.hidden !== settings.hidden);
    const mutedChanged = ("muted" in settings) && (current.muted !== settings.muted);
    if ( (hiddenChanged || mutedChanged) && ui.webrtc.getUserVideoElement(userId) ) ui.webrtc._refreshView(userId);
    if ( "speaking" in settings ) ui.webrtc.setUserIsSpeaking(userId, settings.speaking);
  }
}

/**
 * An implementation of the AVClient which uses the simple-peer library and the Foundry socket server for signaling.
 * Credit to bekit#4213 for identifying simple-peer as a viable technology and providing a POC implementation.
 * @extends {AVClient}
 */
class SimplePeerAVClient extends AVClient {

  /**
   * The local Stream which captures input video and audio
   * @type {MediaStream}
   */
  localStream = null;

  /**
   * The dedicated audio stream used to measure volume levels for voice activity detection.
   * @type {MediaStream}
   */
  levelsStream = null;

  /**
   * A mapping of connected peers
   * @type {Map}
   */
  peers = new Map();

  /**
   * A mapping of connected remote streams
   * @type {Map}
   */
  remoteStreams = new Map();

  /**
   * Has the client been successfully initialized?
   * @type {boolean}
   * @private
   */
  _initialized = false;

  /**
   * Is outbound broadcast of local audio enabled?
   * @type {boolean}
   */
  audioBroadcastEnabled = false;

  /**
   * The polling interval ID for connected users that might have unexpectedly dropped out of our peer network.
   * @type {number|null}
   */
  _connectionPoll = null;

  /* -------------------------------------------- */
  /*  Required AVClient Methods                   */
  /* -------------------------------------------- */

  /** @override */
  async connect() {
    await this._connect();
    clearInterval(this._connectionPoll);
    this._connectionPoll = setInterval(this._connect.bind(this), CONFIG.WebRTC.connectedUserPollIntervalS * 1000);
    return true;
  }

  /* -------------------------------------------- */

  /**
   * Try to establish a peer connection with each user connected to the server.
   * @private
   */
  _connect() {
    const promises = [];
    for ( let user of game.users ) {
      if ( user.isSelf || !user.active ) continue;
      promises.push(this.initializePeerStream(user.id));
    }
    return Promise.all(promises);
  }

  /* -------------------------------------------- */

  /** @override */
  async disconnect() {
    clearInterval(this._connectionPoll);
    this._connectionPoll = null;
    await this.disconnectAll();
    return true;
  }

  /* -------------------------------------------- */

  /** @override */
  async initialize() {
    if ( this._initialized ) return;
    console.debug(`Initializing SimplePeer client connection`);

    // Initialize the local stream
    await this.initializeLocalStream();

    // Set up socket listeners
    this.activateSocketListeners();

    // Register callback to close peer connections when the window is closed
    window.addEventListener("beforeunload", ev => this.disconnectAll());

    // Flag the client as initialized
    this._initialized = true;
  }

  /* -------------------------------------------- */

  /** @override */
  getConnectedUsers() {
    return [...Array.from(this.peers.keys()), game.userId];
  }

  /* -------------------------------------------- */

  /** @override */
  getMediaStreamForUser(userId) {
    return userId === game.user.id ? this.localStream : this.remoteStreams.get(userId);
  }

  /* -------------------------------------------- */

  /** @override */
  getLevelsStreamForUser(userId) {
    return userId === game.userId ? this.levelsStream : this.getMediaStreamForUser(userId);
  }

  /* -------------------------------------------- */

  /** @override */
  isAudioEnabled() {
    return !!this.localStream?.getAudioTracks().length;
  }

  /* -------------------------------------------- */

  /** @override */
  isVideoEnabled() {
    return !!this.localStream?.getVideoTracks().length;
  }

  /* -------------------------------------------- */

  /** @override */
  toggleAudio(enabled) {
    const stream = this.localStream;
    if ( !stream ) return;

    // If "always on" broadcasting is not enabled, don't proceed
    if ( !this.audioBroadcastEnabled || this.isVoicePTT ) return;

    // Enable active broadcasting
    return this.toggleBroadcast(enabled);
  }

  /* -------------------------------------------- */

  /** @override */
  toggleBroadcast(enabled) {
    const stream = this.localStream;
    if ( !stream ) return;
    console.debug(`[SimplePeer] Toggling broadcast of outbound audio: ${enabled}`);
    this.audioBroadcastEnabled = enabled;
    for ( let t of stream.getAudioTracks() ) {
      t.enabled = enabled;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  toggleVideo(enabled) {
    const stream = this.localStream;
    if ( !stream ) return;
    console.debug(`[SimplePeer] Toggling broadcast of outbound video: ${enabled}`);
    for (const track of stream.getVideoTracks()) {
      track.enabled = enabled;
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async setUserVideo(userId, videoElement) {
    const stream = this.getMediaStreamForUser(userId);

    // Set the stream as the video element source
    if ("srcObject" in videoElement) videoElement.srcObject = stream;
    else videoElement.src = window.URL.createObjectURL(stream); // for older browsers

    // Forward volume to the configured audio sink
    if ( videoElement.sinkId === undefined ) {
      return console.warn(`[SimplePeer] Your web browser does not support output audio sink selection`);
    }
    const requestedSink = this.settings.get("client", "audioSink");
    await videoElement.setSinkId(requestedSink).catch(err => {
      console.warn(`[SimplePeer] An error occurred when requesting the output audio device: ${requestedSink}`);
    })
  }

  /* -------------------------------------------- */
  /*  Local Stream Management                     */
  /* -------------------------------------------- */

  /**
   * Initialize a local media stream for the current user
   * @returns {Promise<MediaStream>}
   */
  async initializeLocalStream() {
    console.debug(`[SimplePeer] Initializing local media stream for current User`);

    // If there is already an existing local media stream, terminate it
    if ( this.localStream ) this.localStream.getTracks().forEach(t => t.stop());
    this.localStream = null;

    if ( this.levelsStream ) this.levelsStream.getTracks().forEach(t => t.stop());
    this.levelsStream = null;

    // Determine whether the user can send audio
    const audioSrc = this.settings.get("client", "audioSrc");
    const canBroadcastAudio = this.master.canUserBroadcastAudio(game.user.id);
    const audioParams = (audioSrc && (audioSrc !== "disabled") && canBroadcastAudio) ? {
      deviceId: { ideal: audioSrc }
    } : false;

    // Configure whether the user can send video
    const videoSrc = this.settings.get("client", "videoSrc");
    const canBroadcastVideo = this.master.canUserBroadcastVideo(game.user.id);
    const videoParams = (videoSrc && (videoSrc !== "disabled") && canBroadcastVideo) ? {
      deviceId: { ideal: videoSrc },
      width: { ideal: 320 },
      height: { ideal: 240 }
    } : false;

    // FIXME: Firefox does not allow you to request a specific device, you can only use whatever the browser allows
    // https://bugzilla.mozilla.org/show_bug.cgi?id=1443294#c7
    if ( navigator.userAgent.match(/Firefox/) ) {
      delete videoParams["deviceId"];
    }

    if ( !videoParams && !audioParams ) return null;
    let stream = await this._createMediaStream({video: videoParams, audio: audioParams});
    if ( (videoParams && audioParams) && (stream instanceof Error) ) {
      // Even if the game is set to both audio and video, the user may not have one of those devices, or they might have
      // blocked access to one of them. In those cases we do not want to prevent A/V loading entirely, so we must try
      // each of them separately to see what is available.
      if ( audioParams ) stream = await this._createMediaStream({video: false, audio: audioParams});
      if ( (stream instanceof Error) && videoParams ) {
        stream = await this._createMediaStream({video: videoParams, audio: false});
      }
    }

    if ( stream instanceof Error ) {
      const error = new Error(`[SimplePeer] Unable to acquire user media stream: ${stream.message}`);
      error.stack = stream.stack;
      console.error(error);
      return null;
    }

    this.localStream = stream;
    this.levelsStream = stream.clone();
    this.levelsStream.getVideoTracks().forEach(t => this.levelsStream.removeTrack(t));
    return stream;
  }

  /* -------------------------------------------- */

  /**
   * Attempt to create local media streams.
   * @param {{video: object, audio: object}} params       Parameters for the getUserMedia request.
   * @returns {Promise<MediaStream|Error>}                The created MediaStream or an error.
   * @private
   */
  async _createMediaStream(params) {
    try {
      return await navigator.mediaDevices.getUserMedia(params);
    } catch(err) {
      return err;
    }
  }

  /* -------------------------------------------- */
  /*  Peer Stream Management                      */
  /* -------------------------------------------- */

  /**
   * Listen for Audio/Video updates on the av socket to broker connections between peers
   */
  activateSocketListeners() {
    game.socket.on("av", (request, userId) => {
      if ( request.userId !== game.user.id ) return; // The request is not for us, this shouldn't happen
      switch ( request.action ) {
        case "peer-signal":
          if ( request.activity ) this.master.settings.handleUserActivity(userId, request.activity);
          return this.receiveSignal(userId, request.data);
        case "peer-close":
          return this.disconnectPeer(userId);
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Initialize a stream connection with a new peer
   * @param {string} userId           The Foundry user ID for which the peer stream should be established
   * @returns {Promise<SimplePeer>}   A Promise which resolves once the peer stream is initialized
   */
  async initializePeerStream(userId) {
    const peer = this.peers.get(userId);
    if ( peer?.connected || peer?._connecting ) return peer;
    return this.connectPeer(userId, true);
  }

  /* -------------------------------------------- */

  /**
   * Receive a request to establish a peer signal with some other User id
   * @param {string} userId           The Foundry user ID who is requesting to establish a connection
   * @param {object} data             The connection details provided by SimplePeer
   */
  receiveSignal(userId, data) {
    console.debug(`[SimplePeer] Receiving signal from User [${userId}] to establish initial connection`);
    let peer = this.peers.get(userId);
    if ( !peer ) peer = this.connectPeer(userId, false);
    peer.signal(data);
  }

  /* -------------------------------------------- */

  /**
   * Connect to a peer directly, either as the initiator or as the receiver
   * @param {string} userId           The Foundry user ID with whom we are connecting
   * @param {boolean} isInitiator     Is the current user initiating the connection, or responding to it?
   * @returns {SimplePeer}            The constructed and configured SimplePeer instance
   */
  connectPeer(userId, isInitiator=false) {

    // Create the SimplePeer instance for this connection
    const peer = this._createPeerConnection(userId, isInitiator);
    this.peers.set(userId, peer);

    // Signal to request that a remote user establish a connection with us
    peer.on("signal", data => {
      console.debug(`[SimplePeer] Sending signal to User [${userId}] to establish initial connection`);
      game.socket.emit("av", {
        action: "peer-signal",
        userId: userId,
        data: data,
        activity: this.master.settings.getUser(game.userId)
      }, {recipients: [userId]});
    });

    // Receive a stream provided by a peer
    peer.on("stream", stream => {
      console.debug(`[SimplePeer] Received media stream from User [${userId}]`);
      this.remoteStreams.set(userId, stream);
      this.master.render();
    });

    // Close a connection with a current peer
    peer.on("close", () => {
      console.debug(`[SimplePeer] Closed connection with remote User [${userId}]`);
      return this.disconnectPeer(userId);
    });

    // Handle errors
    peer.on("error", err => {
      if ( err.code !== "ERR_DATA_CHANNEL" ) {
        const error = new Error(`[SimplePeer] An unexpected error occurred with User [${userId}]: ${err.message}`);
        error.stack = err.stack;
        console.error(error);
      }
      if ( peer.connected ) return this.disconnectPeer(userId);
    });

    this.master.render();
    return peer;
  }

  /* -------------------------------------------- */

  /**
   * Create the SimplePeer instance for the desired peer connection.
   * Modules may implement more advanced connection strategies by overriding this method.
   * @param {string} userId           The Foundry user ID with whom we are connecting
   * @param {boolean} isInitiator     Is the current user initiating the connection, or responding to it?
   * @private
   */
  _createPeerConnection(userId, isInitiator) {
    const options = {
      initiator: isInitiator,
      stream: this.localStream
    };

    this._setupCustomTURN(options);
    return new SimplePeer(options);
  }

  /* -------------------------------------------- */

  /**
   * Setup the custom TURN relay to be used in subsequent calls if there is one configured.
   * TURN credentials are mandatory in WebRTC.
   * @param {object} options The SimplePeer configuration object.
   * @private
   */
  _setupCustomTURN(options) {
    const { url, type, username, password } = this.settings.world.turn;
    if ( (type !== "custom") || !url || !username || !password ) return;
    const iceServer = { username, urls: url, credential: password };
    options.config = { iceServers: [iceServer] };
  }

  /* -------------------------------------------- */

  /**
   * Disconnect from a peer by stopping current stream tracks and destroying the SimplePeer instance
   * @param {string} userId           The Foundry user ID from whom we are disconnecting
   * @returns {Promise<void>}         A Promise which resolves once the disconnection is complete
   */
  async disconnectPeer(userId) {

    // Stop audio and video tracks from the remote stream
    const remoteStream = this.remoteStreams.get(userId);
    if ( remoteStream ) {
      this.remoteStreams.delete(userId);
      for ( let track of remoteStream.getTracks() ) {
        await track.stop();
      }
    }

    // Remove the peer
    const peer = this.peers.get(userId);
    if ( peer ) {
      this.peers.delete(userId);
      await peer.destroy();
    }

    // Re-render the UI on disconnection
    this.master.render();
  }

  /* -------------------------------------------- */

  /**
   * Disconnect from all current peer streams
   * @returns {Promise<Array>}       A Promise which resolves once all peers have been disconnected
   */
  async disconnectAll() {
    const promises = [];
    for ( let userId of this.peers.keys() ) {
      promises.push(this.disconnectPeer(userId));
    }
    return Promise.all(promises);
  }

  /* -------------------------------------------- */
  /*  Settings and Configuration                  */
  /* -------------------------------------------- */

  /** @override */
  async onSettingsChanged(changed) {
    const keys = new Set(Object.keys(foundry.utils.flattenObject(changed)));

    // Change audio or video sources
    const sourceChange = ["client.videoSrc", "client.audioSrc"].some(k => keys.has(k));
    if ( sourceChange ) await this.updateLocalStream();

    // Change voice broadcasting mode
    const modeChange = ["client.voice.mode", `client.users.${game.user.id}.muted`].some(k => keys.has(k));
    if ( modeChange ) {
      const isAlways = this.settings.client.voice.mode === "always";
      this.toggleAudio(isAlways && this.master.canUserShareAudio(game.user.id));
      this.master.broadcast(isAlways);
      this.master._initializeUserVoiceDetection(changed.client.voice?.mode);
      ui.webrtc.setUserIsSpeaking(game.user.id, this.master.broadcasting);
    }

    // Re-render the AV camera view
    const renderChange = ["client.audioSink", "client.muteAll", "client.disableVideo"].some(k => keys.has(k));
    if ( sourceChange || renderChange ) this.master.render();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async updateLocalStream() {
    const oldStream = this.localStream;
    await this.initializeLocalStream();
    for ( let peer of this.peers.values() ) {
      if ( oldStream ) peer.removeStream(oldStream);
      if ( this.localStream ) peer.addStream(this.localStream);
    }
    // FIXME: This is a cheat, should be handled elsewhere
    this.master._initializeUserVoiceDetection(this.settings.client.voice.mode);
  }
}

/**
 * Runtime configuration settings for Foundry VTT which exposes a large number of variables which determine how
 * aspects of the software behaves.
 *
 * Unlike the CONST analog which is frozen and immutable, the CONFIG object may be updated during the course of a
 * session or modified by system and module developers to adjust how the application behaves.
 *
 * @type {object}
 */
const CONFIG = globalThis.CONFIG = {

  /**
   * Configure debugging flags to display additional information
   */
  debug: {
    applications: false,
    audio: false,
    combat: false,
    dice: false,
    documents: false,
    fog: {
      extractor: false,
      manager: false
    },
    hooks: false,
    av: false,
    avclient: false,
    mouseInteraction: false,
    time: false,
    keybindings: false,
    polygons: false,
    gamepad: false,
    canvas: {
      primary: {
        bounds: false
      }
    },
    rollParsing: false
  },

  /**
   * Configure the verbosity of compatibility warnings generated throughout the software.
   * The compatibility mode defines the logging level of any displayed warnings.
   * The includePatterns and excludePatterns arrays provide a set of regular expressions which can either only
   * include or specifically exclude certain file paths or warning messages.
   * Exclusion rules take precedence over inclusion rules.
   *
   * @see {@link CONST.COMPATIBILITY_MODES}
   * @type {{mode: number, includePatterns: RegExp[], excludePatterns: RegExp[]}}
   *
   * @example Include Specific Errors
   * ```js
   * const includeRgx = new RegExp("/systems/dnd5e/module/documents/active-effect.mjs");
   * CONFIG.compatibility.includePatterns.push(includeRgx);
   * ```
   *
   * @example Exclude Specific Errors
   * ```js
   * const excludeRgx = new RegExp("/systems/dnd5e/");
   * CONFIG.compatibility.excludePatterns.push(excludeRgx);
   * ```
   *
   * @example Both Include and Exclude
   * ```js
   * const includeRgx = new RegExp("/systems/dnd5e/module/actor/");
   * const excludeRgx = new RegExp("/systems/dnd5e/module/actor/sheets/base.js");
   * CONFIG.compatibility.includePatterns.push(includeRgx);
   * CONFIG.compatibility.excludePatterns.push(excludeRgx);
   * ```
   *
   * @example Targeting more than filenames
   * ```js
   * const includeRgx = new RegExp("applyActiveEffects");
   * CONFIG.compatibility.includePatterns.push(includeRgx);
   * ```
   */
  compatibility: {
    mode: CONST.COMPATIBILITY_MODES.WARNING,
    includePatterns: [],
    excludePatterns: []
  },

  compendium: {
    /**
     * Configure a table of compendium UUID redirects. Must be configured before the game *ready* hook is fired.
     * @type {Record<string, string>}
     *
     * @example Re-map individual UUIDs
     * ```js
     * CONFIG.compendium.uuidRedirects["Compendium.system.heroes.Actor.Tf0JDPzHOrIxz6BH"] = "Compendium.system.villains.Actor.DKYLeIliXXzlAZ2G";
     * ```
     *
     * @example Redirect UUIDs from one compendium to another.
     * ```js
     * CONFIG.compendium.uuidRedirects["Compendium.system.heroes"] = "Compendium.system.villains";
     * ```
     */
    uuidRedirects: {},
  },

  /**
   * Configure the DatabaseBackend used to perform Document operations
   * @type {foundry.data.ClientDatabaseBackend}
   */
  DatabaseBackend: new foundry.data.ClientDatabaseBackend(),

  /**
   * Configuration for the Actor document
   */
  Actor: {
    documentClass: Actor,
    collection: Actors,
    compendiumIndexFields: [],
    compendiumBanner: "ui/banners/actor-banner.webp",
    sidebarIcon: "fas fa-user",
    dataModels: {},
    typeLabels: {},
    typeIcons: {},
    trackableAttributes: {}
  },

  /**
   * Configuration for the Adventure document.
   * Currently for internal use only.
   * @private
   */
  Adventure: {
    documentClass: Adventure,
    exporterClass: AdventureExporter,
    compendiumIndexFields: [],
    compendiumBanner: "ui/banners/adventure-banner.webp",
    sidebarIcon: "fa-solid fa-treasure-chest"
  },

  /**
   * Configuration for the Cards primary Document type
   */
  Cards: {
    collection: CardStacks,
    compendiumIndexFields: [],
    compendiumBanner: "ui/banners/cards-banner.webp",
    documentClass: Cards,
    sidebarIcon: "fa-solid fa-cards",
    dataModels: {},
    presets: {
      pokerDark: {
        type: "deck",
        label: "CARDS.DeckPresetPokerDark",
        src: "cards/poker-deck-dark.json"
      },
      pokerLight: {
        type: "deck",
        label: "CARDS.DeckPresetPokerLight",
        src: "cards/poker-deck-light.json"
      }
    },
    typeLabels: {},
    typeIcons: {
      deck: "fas fa-cards",
      hand: "fa-duotone fa-cards",
      pile: "fa-duotone fa-layer-group"
    }
  },

  /**
   * Configuration for the ChatMessage document
   */
  ChatMessage: {
    documentClass: ChatMessage,
    collection: Messages,
    template: "templates/sidebar/chat-message.html",
    sidebarIcon: "fas fa-comments",
    dataModels: {},
    typeLabels: {},
    typeIcons: {},
    batchSize: 100
  },

  /**
   * Configuration for the Combat document
   */
  Combat: {
    documentClass: Combat,
    collection: CombatEncounters,
    sidebarIcon: "fas fa-swords",
    dataModels: {},
    typeLabels: {},
    typeIcons: {},
    initiative: {
      formula: null,
      decimals: 2
    },
    sounds: {
      epic: {
        label: "COMBAT.Sounds.Epic",
        startEncounter: ["sounds/combat/epic-start-3hit.ogg", "sounds/combat/epic-start-horn.ogg"],
        nextUp: ["sounds/combat/epic-next-horn.ogg"],
        yourTurn: ["sounds/combat/epic-turn-1hit.ogg", "sounds/combat/epic-turn-2hit.ogg"]
      },
      mc: {
        label: "COMBAT.Sounds.MC",
        startEncounter: ["sounds/combat/mc-start-battle.ogg", "sounds/combat/mc-start-begin.ogg", "sounds/combat/mc-start-fight.ogg", "sounds/combat/mc-start-fight2.ogg"],
        nextUp: ["sounds/combat/mc-next-itwillbe.ogg", "sounds/combat/mc-next-makeready.ogg", "sounds/combat/mc-next-youare.ogg"],
        yourTurn: ["sounds/combat/mc-turn-itisyour.ogg", "sounds/combat/mc-turn-itsyour.ogg"]
      }
    }
  },

  /**
   * @typedef {object} DiceFulfillmentConfiguration
   * @property {Record<string, DiceFulfillmentDenomination>} dice  The die denominations available for configuration.
   * @property {Record<string, DiceFulfillmentMethod>} methods     The methods available for fulfillment.
   * @property {string} defaultMethod                              Designate one of the methods to be used by default
   *                                                               for dice fulfillment, if the user hasn't specified
   *                                                               otherwise. Leave this blank to use the configured
   *                                                               randomUniform to generate die rolls.
   */

  /**
   * @typedef {object} DiceFulfillmentDenomination
   * @property {string} label  The human-readable label for the die.
   * @property {string} icon   An icon to display on the configuration sheet.
   */

  /**
   * @typedef {object} DiceFulfillmentMethod
   * @property {string} label                      The human-readable label for the fulfillment method.
   * @property {string} [icon]                     An icon to represent the fulfillment method.
   * @property {boolean} [interactive=false]       Whether this method requires input from the user or if it is
   *                                               fulfilled entirely programmatically.
   * @property {DiceFulfillmentHandler} [handler]  A function to invoke to programmatically fulfil a given term for non-
   *                                               interactive fulfillment methods.
   * @property {typeof RollResolver} [resolver]    A custom RollResolver implementation. If the only interactive methods
   *                                               the user has configured are this method and manual, this resolver
   *                                               will be used to resolve interactive rolls, instead of the default
   *                                               resolver. This resolver must therefore be capable of handling manual
   *                                               rolls.
   */

  /**
   * Only used for non-interactive fulfillment methods. If a die configured to use this fulfillment method is rolled,
   * this handler is called and awaited in order to produce the die roll result.
   * @callback DiceFulfillmentHandler
   * @param {DiceTerm} term           The term being fulfilled.
   * @param {object} [options]        Additional options to configure fulfillment.
   * @returns {Promise<number|void>}  The fulfilled value, or undefined if it could not be fulfilled.
   */

  /**
   * @callback RollFunction
   * @param {...any} args
   * @returns {Promise<number>|number}
   */

  /**
   * Configuration for dice rolling behaviors in the Foundry Virtual Tabletop client.
   * @type {object}
   */
  Dice: {
    /**
     * The Dice types which are supported.
     * @type {Array<typeof DiceTerm>}
     */
    types: [foundry.dice.terms.Die, foundry.dice.terms.FateDie],
    rollModes: Object.entries(CONST.DICE_ROLL_MODES).reduce((obj, e) => {
      let [k, v] = e;
      obj[v] = `CHAT.Roll${k.titleCase()}`;
      return obj;
    }, {}),
    /**
     * Configured Roll class definitions
     * @type {Array<typeof Roll>}
     */
    rolls: [Roll],
    /**
     * Configured DiceTerm class definitions
     * @type {Record<string, typeof RollTerm>}
     */
    termTypes: {
      DiceTerm: foundry.dice.terms.DiceTerm,
      FunctionTerm: foundry.dice.terms.FunctionTerm,
      NumericTerm: foundry.dice.terms.NumericTerm,
      OperatorTerm: foundry.dice.terms.OperatorTerm,
      ParentheticalTerm: foundry.dice.terms.ParentheticalTerm,
      PoolTerm: foundry.dice.terms.PoolTerm,
      StringTerm: foundry.dice.terms.StringTerm
    },
    /**
     * Configured roll terms and the classes they map to.
     * @enum {typeof DiceTerm}
     */
    terms: {
      c: foundry.dice.terms.Coin,
      d: foundry.dice.terms.Die,
      f: foundry.dice.terms.FateDie
    },
    /**
     * A function used to provide random uniform values.
     * @type {function():number}
     */
    randomUniform: foundry.dice.MersenneTwister.random,

    /**
     * A parser implementation for parsing Roll expressions.
     * @type {typeof RollParser}
     */
    parser: foundry.dice.RollParser,

    /**
     * A collection of custom functions that can be included in roll expressions.
     * @type {Record<string, RollFunction>}
     */
    functions: {},

    /**
     * Dice roll fulfillment configuration.
     * @type {DiceFulfillmentConfiguration}
     * @type {{dice: Record<string, DiceFulfillmentDenomination>, methods: Record<string, DiceFulfillmentMethod>}}
     */
    fulfillment: {
      dice: {
        d4: { label: "d4", icon: '<i class="fas fa-dice-d4"></i>' },
        d6: { label: "d6", icon: '<i class="fas fa-dice-d6"></i>' },
        d8: { label: "d8", icon: '<i class="fas fa-dice-d8"></i>' },
        d10: { label: "d10", icon: '<i class="fas fa-dice-d10"></i>' },
        d12: { label: "d12", icon: '<i class="fas fa-dice-d12"></i>' },
        d20: { label: "d20", icon: '<i class="fas fa-dice-d20"></i>' },
        d100: { label: "d100", icon: '<i class="fas fa-percent"></i>' }
      },
      methods: {
        mersenne: {
          label: "DICE.FULFILLMENT.Mersenne",
          interactive: false,
          handler: term => term.mapRandomFace(foundry.dice.MersenneTwister.random())
        },
        manual: {
          label: "DICE.FULFILLMENT.Manual",
          icon: '<i class="fas fa-keyboard"></i>',
          interactive: true
        }
      },
      defaultMethod: ""
    }
  },

  /**
   * Configuration for the FogExploration document
   */
  FogExploration: {
    documentClass: FogExploration,
    collection: FogExplorations
  },

  /**
   * Configuration for the Folder document
   */
  Folder: {
    documentClass: Folder,
    collection: Folders,
    sidebarIcon: "fas fa-folder"
  },

  /**
   * Configuration for Item document
   */
  Item: {
    documentClass: Item,
    collection: Items,
    compendiumIndexFields: [],
    compendiumBanner: "ui/banners/item-banner.webp",
    sidebarIcon: "fas fa-suitcase",
    dataModels: {},
    typeLabels: {},
    typeIcons: {}
  },

  /**
   * Configuration for the JournalEntry document
   */
  JournalEntry: {
    documentClass: JournalEntry,
    collection: Journal,
    compendiumIndexFields: [],
    compendiumBanner: "ui/banners/journalentry-banner.webp",
    noteIcons: {
      Anchor: "icons/svg/anchor.svg",
      Barrel: "icons/svg/barrel.svg",
      Book: "icons/svg/book.svg",
      Bridge: "icons/svg/bridge.svg",
      Cave: "icons/svg/cave.svg",
      Castle: "icons/svg/castle.svg",
      Chest: "icons/svg/chest.svg",
      City: "icons/svg/city.svg",
      Coins: "icons/svg/coins.svg",
      Fire: "icons/svg/fire.svg",
      "Hanging Sign": "icons/svg/hanging-sign.svg",
      House: "icons/svg/house.svg",
      Mountain: "icons/svg/mountain.svg",
      "Oak Tree": "icons/svg/oak.svg",
      Obelisk: "icons/svg/obelisk.svg",
      Pawprint: "icons/svg/pawprint.svg",
      Ruins: "icons/svg/ruins.svg",
      Skull: "icons/svg/skull.svg",
      Statue: "icons/svg/statue.svg",
      Sword: "icons/svg/sword.svg",
      Tankard: "icons/svg/tankard.svg",
      Temple: "icons/svg/temple.svg",
      Tower: "icons/svg/tower.svg",
      Trap: "icons/svg/trap.svg",
      Village: "icons/svg/village.svg",
      Waterfall: "icons/svg/waterfall.svg",
      Windmill: "icons/svg/windmill.svg"
    },
    sidebarIcon: "fas fa-book-open"
  },

  /**
   * Configuration for the Macro document
   */
  Macro: {
    documentClass: Macro,
    collection: Macros,
    compendiumIndexFields: [],
    compendiumBanner: "ui/banners/macro-banner.webp",
    sidebarIcon: "fas fa-code"
  },

  /**
   * Configuration for the Playlist document
   */
  Playlist: {
    documentClass: Playlist,
    collection: Playlists,
    compendiumIndexFields: [],
    compendiumBanner: "ui/banners/playlist-banner.webp",
    sidebarIcon: "fas fa-music",
    autoPreloadSeconds: 20
  },

  /**
   * Configuration for RollTable random draws
   */
  RollTable: {
    documentClass: RollTable,
    collection: RollTables,
    compendiumIndexFields: ["formula"],
    compendiumBanner: "ui/banners/rolltable-banner.webp",
    sidebarIcon: "fas fa-th-list",
    resultIcon: "icons/svg/d20-black.svg",
    resultTemplate: "templates/dice/table-result.html"
  },

  /**
   * Configuration for the Scene document
   */
  Scene: {
    documentClass: Scene,
    collection: Scenes,
    compendiumIndexFields: [],
    compendiumBanner: "ui/banners/scene-banner.webp",
    sidebarIcon: "fas fa-map"
  },

  Setting: {
    documentClass: Setting,
    collection: WorldSettings
  },

  /**
   * Configuration for the User document
   */
  User: {
    documentClass: User,
    collection: Users
  },

  /* -------------------------------------------- */
  /*  Canvas                                      */
  /* -------------------------------------------- */

  /**
   * Configuration settings for the Canvas and its contained layers and objects
   * @type {object}
   */
  Canvas: {
    blurStrength: 8,
    blurQuality: 4,
    darknessColor: 0x303030,
    daylightColor: 0xEEEEEE,
    brightestColor: 0xFFFFFF,
    chatBubblesClass: ChatBubbles,
    darknessLightPenalty: 0.25,
    dispositionColors: {
      HOSTILE: 0xE72124,
      NEUTRAL: 0xF1D836,
      FRIENDLY: 0x43DFDF,
      INACTIVE: 0x555555,
      PARTY: 0x33BC4E,
      CONTROLLED: 0xFF9829,
      SECRET: 0xA612D4
    },
    /**
     * The class used to render door control icons.
     * @type {typeof DoorControl}
     */
    doorControlClass: DoorControl,
    exploredColor: 0x000000,
    unexploredColor: 0x000000,
    darknessToDaylightAnimationMS: 10000,
    daylightToDarknessAnimationMS: 10000,
    darknessSourceClass: foundry.canvas.sources.PointDarknessSource,
    lightSourceClass: foundry.canvas.sources.PointLightSource,
    globalLightSourceClass: foundry.canvas.sources.GlobalLightSource,
    visionSourceClass: foundry.canvas.sources.PointVisionSource,
    soundSourceClass: foundry.canvas.sources.PointSoundSource,
    groups: {
      hidden: {
        groupClass: HiddenCanvasGroup,
        parent: "stage"
      },
      rendered: {
        groupClass: RenderedCanvasGroup,
        parent: "stage"
      },
      environment: {
        groupClass: EnvironmentCanvasGroup,
        parent: "rendered"
      },
      primary: {
        groupClass: PrimaryCanvasGroup,
        parent: "environment"
      },
      effects: {
        groupClass: EffectsCanvasGroup,
        parent: "environment"
      },
      visibility: {
        groupClass: CanvasVisibility,
        parent: "rendered"
      },
      interface: {
        groupClass: InterfaceCanvasGroup,
        parent: "rendered",
        zIndexDrawings: 500,
        zIndexScrollingText: 1100
      },
      overlay: {
        groupClass: OverlayCanvasGroup,
        parent: "stage"
      }
    },
    layers: {
      weather: {
        layerClass: WeatherEffects,
        group: "primary"
      },
      grid: {
        layerClass: GridLayer,
        group: "interface"
      },
      regions: {
        layerClass: RegionLayer,
        group: "interface"
      },
      drawings: {
        layerClass: DrawingsLayer,
        group: "interface"
      },
      templates: {
        layerClass: TemplateLayer,
        group: "interface"
      },
      tiles: {
        layerClass: TilesLayer,
        group: "interface"
      },
      walls: {
        layerClass: WallsLayer,
        group: "interface"
      },
      tokens: {
        layerClass: TokenLayer,
        group: "interface"
      },
      sounds: {
        layerClass: SoundsLayer,
        group: "interface"
      },
      lighting: {
        layerClass: LightingLayer,
        group: "interface"
      },
      notes: {
        layerClass: NotesLayer,
        group: "interface"
      },
      controls: {
        layerClass: ControlsLayer,
        group: "interface"
      }
    },
    lightLevels: {
      dark: 0,
      halfdark: 0.5,
      dim: 0.25,
      bright: 1.0
    },
    fogManager: FogManager,
    /**
     * @enum {typeof PointSourcePolygon}
     */
    polygonBackends: {
      sight: ClockwiseSweepPolygon,
      light: ClockwiseSweepPolygon,
      sound: ClockwiseSweepPolygon,
      move: ClockwiseSweepPolygon
    },
    darknessSourcePaddingMultiplier: 0.5,
    visibilityFilter: VisibilityFilter,
    visualEffectsMaskingFilter: VisualEffectsMaskingFilter,
    rulerClass: Ruler,
    dragSpeedModifier: 0.8,
    maxZoom: 3.0,
    objectBorderThickness: 4,
    gridStyles: {
      solidLines: {
        label: "GRID.STYLES.SolidLines",
        shaderClass: GridShader,
        shaderOptions: {
          style: 0
        }
      },
      dashedLines: {
        label: "GRID.STYLES.DashedLines",
        shaderClass: GridShader,
        shaderOptions: {
          style: 1
        }
      },
      dottedLines: {
        label: "GRID.STYLES.DottedLines",
        shaderClass: GridShader,
        shaderOptions: {
          style: 2
        }
      },
      squarePoints: {
        label: "GRID.STYLES.SquarePoints",
        shaderClass: GridShader,
        shaderOptions: {
          style: 3
        }
      },
      diamondPoints: {
        label: "GRID.STYLES.DiamondPoints",
        shaderClass: GridShader,
        shaderOptions: {
          style: 4
        }
      },
      roundPoints: {
        label: "GRID.STYLES.RoundPoints",
        shaderClass: GridShader,
        shaderOptions: {
          style: 5
        }
      }
    },

    /**
     * A light source animation configuration object.
     * @typedef {Record<string, {label: string, animation: Function, backgroundShader: AdaptiveBackgroundShader,
     * illuminationShader: AdaptiveIlluminationShader,
     * colorationShader: AdaptiveColorationShader}>} LightSourceAnimationConfig
     */

    /** @type {LightSourceAnimationConfig} */
    lightAnimations: {
      flame: {
        label: "LIGHT.AnimationFlame",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateFlickering,
        illuminationShader: FlameIlluminationShader,
        colorationShader: FlameColorationShader
      },
      torch: {
        label: "LIGHT.AnimationTorch",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTorch,
        illuminationShader: TorchIlluminationShader,
        colorationShader: TorchColorationShader
      },
      revolving: {
        label: "LIGHT.AnimationRevolving",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        colorationShader: RevolvingColorationShader
      },
      siren: {
        label: "LIGHT.AnimationSiren",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTorch,
        illuminationShader: SirenIlluminationShader,
        colorationShader: SirenColorationShader
      },
      pulse: {
        label: "LIGHT.AnimationPulse",
        animation: foundry.canvas.sources.PointLightSource.prototype.animatePulse,
        illuminationShader: PulseIlluminationShader,
        colorationShader: PulseColorationShader
      },
      chroma: {
        label: "LIGHT.AnimationChroma",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        colorationShader: ChromaColorationShader
      },
      wave: {
        label: "LIGHT.AnimationWave",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        illuminationShader: WaveIlluminationShader,
        colorationShader: WaveColorationShader
      },
      fog: {
        label: "LIGHT.AnimationFog",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        colorationShader: FogColorationShader
      },
      sunburst: {
        label: "LIGHT.AnimationSunburst",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        illuminationShader: SunburstIlluminationShader,
        colorationShader: SunburstColorationShader
      },
      dome: {
        label: "LIGHT.AnimationLightDome",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        colorationShader: LightDomeColorationShader
      },
      emanation: {
        label: "LIGHT.AnimationEmanation",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        colorationShader: EmanationColorationShader
      },
      hexa: {
        label: "LIGHT.AnimationHexaDome",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        colorationShader: HexaDomeColorationShader
      },
      ghost: {
        label: "LIGHT.AnimationGhostLight",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        illuminationShader: GhostLightIlluminationShader,
        colorationShader: GhostLightColorationShader
      },
      energy: {
        label: "LIGHT.AnimationEnergyField",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        colorationShader: EnergyFieldColorationShader
      },
      vortex: {
        label: "LIGHT.AnimationVortex",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        illuminationShader: VortexIlluminationShader,
        colorationShader: VortexColorationShader
      },
      witchwave: {
        label: "LIGHT.AnimationBewitchingWave",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        illuminationShader: BewitchingWaveIlluminationShader,
        colorationShader: BewitchingWaveColorationShader
      },
      rainbowswirl: {
        label: "LIGHT.AnimationSwirlingRainbow",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        colorationShader: SwirlingRainbowColorationShader
      },
      radialrainbow: {
        label: "LIGHT.AnimationRadialRainbow",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        colorationShader: RadialRainbowColorationShader
      },
      fairy: {
        label: "LIGHT.AnimationFairyLight",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        illuminationShader: FairyLightIlluminationShader,
        colorationShader: FairyLightColorationShader
      },
      grid: {
        label: "LIGHT.AnimationForceGrid",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        colorationShader: ForceGridColorationShader
      },
      starlight: {
        label: "LIGHT.AnimationStarLight",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        colorationShader: StarLightColorationShader
      },
      smokepatch: {
        label: "LIGHT.AnimationSmokePatch",
        animation: foundry.canvas.sources.PointLightSource.prototype.animateTime,
        illuminationShader: SmokePatchIlluminationShader,
        colorationShader: SmokePatchColorationShader
      }
    },

    /**
     * A darkness source animation configuration object.
     * @typedef {Record<string, {label: string, animation: Function,
     * darknessShader: AdaptiveDarknessShader}>} DarknessSourceAnimationConfig
     */

    /** @type {DarknessSourceAnimationConfig} */
    darknessAnimations: {
      magicalGloom: {
        label: "LIGHT.AnimationMagicalGloom",
        animation: foundry.canvas.sources.PointDarknessSource.prototype.animateTime,
        darknessShader: MagicalGloomDarknessShader
      },
      roiling: {
        label: "LIGHT.AnimationRoilingMass",
        animation: foundry.canvas.sources.PointDarknessSource.prototype.animateTime,
        darknessShader: RoilingDarknessShader
      },
      hole: {
        label: "LIGHT.AnimationBlackHole",
        animation: foundry.canvas.sources.PointDarknessSource.prototype.animateTime,
        darknessShader: BlackHoleDarknessShader
      }
    },

    /**
     * A registry of Scenes which are managed by a specific SceneManager class.
     * @type {Record<string, typeof foundry.canvas.SceneManager>}
     */
    managedScenes: {},

    pings: {
      types: {
        PULSE: "pulse",
        ALERT: "alert",
        PULL: "chevron",
        ARROW: "arrow"
      },
      styles: {
        alert: {
          class: AlertPing,
          color: "#ff0000",
          size: 1.5,
          duration: 900
        },
        arrow: {
          class: ArrowPing,
          size: 1,
          duration: 900
        },
        chevron: {
          class: ChevronPing,
          size: 1,
          duration: 2000
        },
        pulse: {
          class: PulsePing,
          size: 1.5,
          duration: 900
        }
      },
      pullSpeed: 700
    },
    targeting: {
      size: .15
    },

    /**
     * The hover-fading configuration.
     * @type {object}
     */
    hoverFade: {
      /**
       * The delay in milliseconds before the (un)faded animation starts on (un)hover.
       * @type {number}
       */
      delay: 250,

      /**
       * The duration in milliseconds of the (un)fade animation on (un)hover.
       * @type {number}
       */
      duration: 750
    },

    /* -------------------------------------------- */
    /*  Transcoders                                 */
    /* -------------------------------------------- */

    /**
     * Allow specific transcoders for assets
     * @type {Record<string, boolean>}
     */
    transcoders: {
      basis: false    // set to true to activate basis support
    },

    /* -------------------------------------------- */

    /**
     * The set of VisionMode definitions which are available to be used for Token vision.
     * @type {Record<string, VisionMode>}
     */
    visionModes: {

      // Default (Basic) Vision
      basic: new VisionMode({
        id: "basic",
        label: "VISION.ModeBasicVision",
        vision: {
          defaults: { attenuation: 0, contrast: 0, saturation: 0, brightness: 0 },
          preferred: true // Takes priority over other vision modes
        }
      }),

      // Darkvision
      darkvision: new VisionMode({
        id: "darkvision",
        label: "VISION.ModeDarkvision",
        canvas: {
          shader: ColorAdjustmentsSamplerShader,
          uniforms: { contrast: 0, saturation: -1.0, brightness: 0 }
        },
        lighting: {
          levels: {
            [VisionMode.LIGHTING_LEVELS.DIM]: VisionMode.LIGHTING_LEVELS.BRIGHT
          },
          background: { visibility: VisionMode.LIGHTING_VISIBILITY.REQUIRED }
        },
        vision: {
          darkness: { adaptive: false },
          defaults: { attenuation: 0, contrast: 0, saturation: -1.0, brightness: 0 }
        }
      }),

      // Darkvision
      monochromatic: new VisionMode({
        id: "monochromatic",
        label: "VISION.ModeMonochromatic",
        canvas: {
          shader: ColorAdjustmentsSamplerShader,
          uniforms: { contrast: 0, saturation: -1.0, brightness: 0 }
        },
        lighting: {
          background: {
            postProcessingModes: ["SATURATION"],
            uniforms: { saturation: -1.0, tint: [1, 1, 1] }
          },
          illumination: {
            postProcessingModes: ["SATURATION"],
            uniforms: { saturation: -1.0, tint: [1, 1, 1] }
          },
          coloration: {
            postProcessingModes: ["SATURATION"],
            uniforms: { saturation: -1.0, tint: [1, 1, 1] }
          }
        },
        vision: {
          darkness: { adaptive: false },
          defaults: { attenuation: 0, contrast: 0, saturation: -1, brightness: 0 }
        }
      }),

      // Blindness
      blindness: new VisionMode({
        id: "blindness",
        label: "VISION.ModeBlindness",
        tokenConfig: false,
        canvas: {
          shader: ColorAdjustmentsSamplerShader,
          uniforms: { contrast: -0.75, saturation: -1, exposure: -0.3 }
        },
        lighting: {
          background: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED },
          illumination: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED },
          coloration: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED }
        },
        vision: {
          darkness: { adaptive: false },
          defaults: { color: null, attenuation: 0, contrast: -0.5, saturation: -1, brightness: -1 }
        }
      }),

      // Tremorsense
      tremorsense: new VisionMode({
        id: "tremorsense",
        label: "VISION.ModeTremorsense",
        canvas: {
          shader: ColorAdjustmentsSamplerShader,
          uniforms: { contrast: 0, saturation: -0.8, exposure: -0.65 }
        },
        lighting: {
          background: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED },
          illumination: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED },
          coloration: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED },
          darkness: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED }
        },
        vision: {
          darkness: { adaptive: false },
          defaults: { attenuation: 0, contrast: 0.2, saturation: -0.3, brightness: 1 },
          background: { shader: WaveBackgroundVisionShader },
          coloration: { shader: WaveColorationVisionShader }
        }
      }, {animated: true}),

      // Light Amplification
      lightAmplification: new VisionMode({
        id: "lightAmplification",
        label: "VISION.ModeLightAmplification",
        canvas: {
          shader: AmplificationSamplerShader,
          uniforms: { saturation: -0.5, tint: [0.38, 0.8, 0.38] }
        },
        lighting: {
          background: {
            visibility: VisionMode.LIGHTING_VISIBILITY.REQUIRED,
            postProcessingModes: ["SATURATION", "EXPOSURE"],
            uniforms: { saturation: -0.5, exposure: 1.5, tint: [0.38, 0.8, 0.38] }
          },
          illumination: {
            postProcessingModes: ["SATURATION"],
            uniforms: { saturation: -0.5 }
          },
          coloration: {
            postProcessingModes: ["SATURATION", "EXPOSURE"],
            uniforms: { saturation: -0.5, exposure: 1.5, tint: [0.38, 0.8, 0.38] }
          },
          levels: {
            [VisionMode.LIGHTING_LEVELS.DIM]: VisionMode.LIGHTING_LEVELS.BRIGHT,
            [VisionMode.LIGHTING_LEVELS.BRIGHT]: VisionMode.LIGHTING_LEVELS.BRIGHTEST
          }
        },
        vision: {
          darkness: { adaptive: false },
          defaults: { attenuation: 0, contrast: 0, saturation: -0.5, brightness: 1 },
          background: { shader: AmplificationBackgroundVisionShader }
        }
      })
    },

    /* -------------------------------------------- */

    /**
     * The set of DetectionMode definitions which are available to be used for visibility detection.
     * @type {Record<string, DetectionMode>}
     */
    detectionModes: {
      lightPerception: new DetectionModeLightPerception({
        id: "lightPerception",
        label: "DETECTION.LightPerception",
        type: DetectionMode.DETECTION_TYPES.SIGHT
      }),
      basicSight: new DetectionModeBasicSight({
        id: "basicSight",
        label: "DETECTION.BasicSight",
        type: DetectionMode.DETECTION_TYPES.SIGHT
      }),
      seeInvisibility: new DetectionModeInvisibility({
        id: "seeInvisibility",
        label: "DETECTION.SeeInvisibility",
        type: DetectionMode.DETECTION_TYPES.SIGHT
      }),
      senseInvisibility: new DetectionModeInvisibility({
        id: "senseInvisibility",
        label: "DETECTION.SenseInvisibility",
        walls: false,
        angle: false,
        type: DetectionMode.DETECTION_TYPES.OTHER
      }),
      feelTremor: new DetectionModeTremor({
        id: "feelTremor",
        label: "DETECTION.FeelTremor",
        walls: false,
        angle: false,
        type: DetectionMode.DETECTION_TYPES.MOVE
      }),
      seeAll: new DetectionModeAll({
        id: "seeAll",
        label: "DETECTION.SeeAll",
        type: DetectionMode.DETECTION_TYPES.SIGHT
      }),
      senseAll: new DetectionModeAll({
        id: "senseAll",
        label: "DETECTION.SenseAll",
        walls: false,
        angle: false,
        type: DetectionMode.DETECTION_TYPES.OTHER
      })
    }
  },

  /* -------------------------------------------- */

  /**
   * Configure the default Token text style so that it may be reused and overridden by modules
   * @type {PIXI.TextStyle}
   */
  canvasTextStyle: new PIXI.TextStyle({
    fontFamily: "Signika",
    fontSize: 36,
    fill: "#FFFFFF",
    stroke: "#111111",
    strokeThickness: 1,
    dropShadow: true,
    dropShadowColor: "#000000",
    dropShadowBlur: 2,
    dropShadowAngle: 0,
    dropShadowDistance: 0,
    align: "center",
    wordWrap: false,
    padding: 1
  }),

  /**
   * Available Weather Effects implementations
   * @typedef {Object} WeatherAmbienceConfiguration
   * @param {string} id
   * @param {string} label
   * @param {{enabled: boolean, blendMode: PIXI.BLEND_MODES}} filter
   * @param {WeatherEffectConfiguration[]} effects
   *
   * @typedef {Object} WeatherEffectConfiguration
   * @param {string} id
   * @param {typeof ParticleEffect|WeatherShaderEffect} effectClass
   * @param {PIXI.BLEND_MODES} blendMode
   * @param {object} config
   */
  weatherEffects: {
    leaves: {
      id: "leaves",
      label: "WEATHER.AutumnLeaves",
      effects: [{
        id: "leavesParticles",
        effectClass: AutumnLeavesWeatherEffect
      }]
    },
    rain: {
      id: "rain",
      label: "WEATHER.Rain",
      filter: {
        enabled: false
      },
      effects: [{
        id: "rainShader",
        effectClass: WeatherShaderEffect,
        shaderClass: RainShader,
        blendMode: PIXI.BLEND_MODES.SCREEN,
        config: {
          opacity: 0.25,
          tint: [0.7, 0.9, 1.0],
          intensity: 1,
          strength: 1,
          rotation: 0.2618,
          speed: 0.2,
        }
      }]
    },
    rainStorm: {
      id: "rainStorm",
      label: "WEATHER.RainStorm",
      filter: {
        enabled: false
      },
      effects: [{
        id: "fogShader",
        effectClass: WeatherShaderEffect,
        shaderClass: FogShader,
        blendMode: PIXI.BLEND_MODES.SCREEN,
        performanceLevel: 2,
        config: {
          slope: 1.5,
          intensity: 0.050,
          speed: -55.0,
          scale: 25,
        }
      },
      {
        id: "rainShader",
        effectClass: WeatherShaderEffect,
        shaderClass: RainShader,
        blendMode: PIXI.BLEND_MODES.SCREEN,
        config: {
          opacity: 0.45,
          tint: [0.7, 0.9, 1.0],
          intensity: 1.5,
          strength: 1.5,
          rotation: 0.5236,
          speed: 0.30,
        }
      }]
    },
    fog: {
      id: "fog",
      label: "WEATHER.Fog",
      filter: {
        enabled: false
      },
      effects: [{
        id: "fogShader",
        effectClass: WeatherShaderEffect,
        shaderClass: FogShader,
        blendMode: PIXI.BLEND_MODES.SCREEN,
        config: {
          slope: 0.45,
          intensity: 0.4,
          speed: 0.4,
        }
      }]
    },
    snow: {
      id: "snow",
      label: "WEATHER.Snow",
      filter: {
        enabled: false
      },
      effects: [{
        id: "snowShader",
        effectClass: WeatherShaderEffect,
        shaderClass: SnowShader,
        blendMode: PIXI.BLEND_MODES.SCREEN,
        config: {
          tint: [0.85, 0.95, 1],
          direction: 0.5,
          speed: 2,
          scale: 2.5,
        }
      }]
    },
    blizzard: {
      id: "blizzard",
      label: "WEATHER.Blizzard",
      filter: {
        enabled: false
      },
      effects: [{
        id: "snowShader",
        effectClass: WeatherShaderEffect,
        shaderClass: SnowShader,
        blendMode: PIXI.BLEND_MODES.SCREEN,
        config: {
          tint: [0.95, 1, 1],
          direction: 0.80,
          speed: 8,
          scale: 2.5,
        }
      },
      {
        id: "fogShader",
        effectClass: WeatherShaderEffect,
        shaderClass: FogShader,
        blendMode: PIXI.BLEND_MODES.SCREEN,
        performanceLevel: 2,
        config: {
          slope: 1.0,
          intensity: 0.15,
          speed: -4.0,
        }
      }]
    }
  },

  /**
   * The control icons used for rendering common HUD operations
   * @type {object}
   */
  controlIcons: {
    combat: "icons/svg/combat.svg",
    visibility: "icons/svg/cowled.svg",
    effects: "icons/svg/aura.svg",
    lock: "icons/svg/padlock.svg",
    up: "icons/svg/up.svg",
    down: "icons/svg/down.svg",
    defeated: "icons/svg/skull.svg",
    light: "icons/svg/light.svg",
    lightOff: "icons/svg/light-off.svg",
    template: "icons/svg/explosion.svg",
    sound: "icons/svg/sound.svg",
    soundOff: "icons/svg/sound-off.svg",
    doorClosed: "icons/svg/door-closed-outline.svg",
    doorOpen: "icons/svg/door-open-outline.svg",
    doorSecret: "icons/svg/door-secret-outline.svg",
    doorLocked: "icons/svg/door-locked-outline.svg",
    wallDirection: "icons/svg/wall-direction.svg"
  },

  /**
   * @typedef {FontFaceDescriptors} FontDefinition
   * @property {string} urls  An array of remote URLs the font files exist at.
   */

  /**
   * @typedef {object} FontFamilyDefinition
   * @property {boolean} editor          Whether the font is available in the rich text editor. This will also enable it
   *                                     for notes and drawings.
   * @property {FontDefinition[]} fonts  Individual font face definitions for this font family. If this is empty, the
   *                                     font family may only be loaded from the client's OS-installed fonts.
   */

  /**
   * A collection of fonts to load either from the user's local system, or remotely.
   * @type {Record<string, FontFamilyDefinition>}
   */
  fontDefinitions: {
    Arial: {editor: true, fonts: []},
    Amiri: {
      editor: true,
      fonts: [
        {urls: ["fonts/amiri/amiri-regular.woff2"]},
        {urls: ["fonts/amiri/amiri-bold.woff2"], weight: 700}
      ]
    },
    "Bruno Ace": {editor: true, fonts: [
      {urls: ["fonts/bruno-ace/bruno-ace.woff2"]}
    ]},
    Courier: {editor: true, fonts: []},
    "Courier New": {editor: true, fonts: []},
    "Modesto Condensed": {
      editor: true,
      fonts: [
        {urls: ["fonts/modesto-condensed/modesto-condensed.woff2"]},
        {urls: ["fonts/modesto-condensed/modesto-condensed-bold.woff2"], weight: 700}
      ]
    },
    Signika: {
      editor: true,
      fonts: [
        {urls: ["fonts/signika/signika-regular.woff2"]},
        {urls: ["fonts/signika/signika-bold.woff2"], weight: 700}
      ]
    },
    Times: {editor: true, fonts: []},
    "Times New Roman": {editor: true, fonts: []}
  },

  /**
   * The default font family used for text labels on the PIXI Canvas
   * @type {string}
   */
  defaultFontFamily: "Signika",

  /**
   * @typedef {object} _StatusEffectConfig    Configured status effects which are recognized by the game system
   * @property {string} id                    A string identifier for the effect
   * @property {string} label                 Alias for ActiveEffectData#name (deprecated)
   * @property {string} icon                  Alias for ActiveEffectData#img (deprecated)
   * @property {boolean|{actorTypes?: string[]}} [hud=true]  Should this effect be selectable in the Token HUD?
   *                                          This effect is only selectable in the Token HUD if the Token's
   *                                          Actor sub-type is one of the configured ones.
   */

  /**
   * Configured status effects which are recognized by the game system.
   * @typedef {_StatusEffectConfig & Partial<ActiveEffectData>} StatusEffectConfig
   */

  /**
   * The array of status effects which can be applied to an Actor.
   * @type {Array<StatusEffectConfig>}
   */
  statusEffects: [
    {
      id: "dead",
      name: "EFFECT.StatusDead",
      img: "icons/svg/skull.svg"
    },
    {
      id: "unconscious",
      name: "EFFECT.StatusUnconscious",
      img: "icons/svg/unconscious.svg"
    },
    {
      id: "sleep",
      name: "EFFECT.StatusAsleep",
      img: "icons/svg/sleep.svg"
    },
    {
      id: "stun",
      name: "EFFECT.StatusStunned",
      img: "icons/svg/daze.svg"
    },
    {
      id: "prone",
      name: "EFFECT.StatusProne",
      img: "icons/svg/falling.svg"
    },
    {
      id: "restrain",
      name: "EFFECT.StatusRestrained",
      img: "icons/svg/net.svg"
    },
    {
      id: "paralysis",
      name: "EFFECT.StatusParalysis",
      img: "icons/svg/paralysis.svg"
    },
    {
      id: "fly",
      name: "EFFECT.StatusFlying",
      img: "icons/svg/wing.svg"
    },
    {
      id: "blind",
      name: "EFFECT.StatusBlind",
      img: "icons/svg/blind.svg"
    },
    {
      id: "deaf",
      name: "EFFECT.StatusDeaf",
      img: "icons/svg/deaf.svg"
    },
    {
      id: "silence",
      name: "EFFECT.StatusSilenced",
      img: "icons/svg/silenced.svg"
    },
    {
      id: "fear",
      name: "EFFECT.StatusFear",
      img: "icons/svg/terror.svg"
    },
    {
      id: "burning",
      name: "EFFECT.StatusBurning",
      img: "icons/svg/fire.svg"
    },
    {
      id: "frozen",
      name: "EFFECT.StatusFrozen",
      img: "icons/svg/frozen.svg"
    },
    {
      id: "shock",
      name: "EFFECT.StatusShocked",
      img: "icons/svg/lightning.svg"
    },
    {
      id: "corrode",
      name: "EFFECT.StatusCorrode",
      img: "icons/svg/acid.svg"
    },
    {
      id: "bleeding",
      name: "EFFECT.StatusBleeding",
      img: "icons/svg/blood.svg"
    },
    {
      id: "disease",
      name: "EFFECT.StatusDisease",
      img: "icons/svg/biohazard.svg"
    },
    {
      id: "poison",
      name: "EFFECT.StatusPoison",
      img: "icons/svg/poison.svg"
    },
    {
      id: "curse",
      name: "EFFECT.StatusCursed",
      img: "icons/svg/sun.svg"
    },
    {
      id: "regen",
      name: "EFFECT.StatusRegen",
      img: "icons/svg/regen.svg"
    },
    {
      id: "degen",
      name: "EFFECT.StatusDegen",
      img: "icons/svg/degen.svg"
    },
    {
      id: "hover",
      name: "EFFECT.StatusHover",
      img: "icons/svg/wingfoot.svg"
    },
    {
      id: "burrow",
      name: "EFFECT.StatusBurrow",
      img: "icons/svg/mole.svg"
    },
    {
      id: "upgrade",
      name: "EFFECT.StatusUpgrade",
      img: "icons/svg/upgrade.svg"
    },
    {
      id: "downgrade",
      name: "EFFECT.StatusDowngrade",
      img: "icons/svg/downgrade.svg"
    },
    {
      id: "invisible",
      name: "EFFECT.StatusInvisible",
      img: "icons/svg/invisible.svg"
    },
    {
      id: "target",
      name: "EFFECT.StatusTarget",
      img: "icons/svg/target.svg"
    },
    {
      id: "eye",
      name: "EFFECT.StatusMarked",
      img: "icons/svg/eye.svg"
    },
    {
      id: "bless",
      name: "EFFECT.StatusBlessed",
      img: "icons/svg/angel.svg"
    },
    {
      id: "fireShield",
      name: "EFFECT.StatusFireShield",
      img: "icons/svg/fire-shield.svg"
    },
    {
      id: "coldShield",
      name: "EFFECT.StatusIceShield",
      img: "icons/svg/ice-shield.svg"
    },
    {
      id: "magicShield",
      name: "EFFECT.StatusMagicShield",
      img: "icons/svg/mage-shield.svg"
    },
    {
      id: "holyShield",
      name: "EFFECT.StatusHolyShield",
      img: "icons/svg/holy-shield.svg"
    }
  ].map(status => {
    /** @deprecated since v12 */
    for ( const [oldKey, newKey] of Object.entries({label: "name", icon: "img"}) ) {
      const msg = `StatusEffectConfig#${oldKey} has been deprecated in favor of StatusEffectConfig#${newKey}`;
      Object.defineProperty(status, oldKey, {
        get() {
          foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
          return this[newKey];
        },
        set(value) {
          foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true});
          this[newKey] = value;
        },
        enumerable: false,
        configurable: true
      });
    }
    return status;
  }),

  /**
   * A mapping of status effect IDs which provide some additional mechanical integration.
   * @enum {string}
   */
  specialStatusEffects: {
    DEFEATED: "dead",
    INVISIBLE: "invisible",
    BLIND: "blind",
    BURROW: "burrow",
    HOVER: "hover",
    FLY: "fly"
  },

  /**
   * A mapping of core audio effects used which can be replaced by systems or mods
   * @type {object}
   */
  sounds: {
    dice: "sounds/dice.wav",
    lock: "sounds/lock.wav",
    notification: "sounds/notify.wav",
    combat: "sounds/drums.wav"
  },

  /**
   * Define the set of supported languages for localization
   * @type {{string, string}}
   */
  supportedLanguages: {
    en: "English"
  },

  /**
   * Localization constants.
   * @type {object}
   */
  i18n: {
    /**
     * In operations involving the document index, search prefixes must have at least this many characters to avoid too
     * large a search space. Languages that have hundreds or thousands of characters will typically have very shallow
     * search trees, so it should be safe to lower this number in those cases.
     */
    searchMinimumCharacterLength: 4
  },

  /**
   * Configuration for time tracking
   * @type {{turnTime: number}}
   */
  time: {
    turnTime: 0,
    roundTime: 0
  },

  /* -------------------------------------------- */
  /*  Embedded Documents                          */
  /* -------------------------------------------- */

  /**
   * Configuration for the ActiveEffect embedded document type
   */
  ActiveEffect: {
    documentClass: ActiveEffect,
    dataModels: {},
    typeLabels: {},
    typeIcons: {},

    /**
     * If true, Active Effects on Items will be copied to the Actor when the Item is created on the Actor if the
     * Active Effect's transfer property is true, and will be deleted when that Item is deleted from the Actor.
     * If false, Active Effects are never copied to the Actor, but will still apply to the Actor from within the Item
     * if the transfer property on the Active Effect is true.
     * @deprecated since v11
     */
    legacyTransferral: true
  },

  /**
   * Configuration for the ActorDelta embedded document type.
   */
  ActorDelta: {
    documentClass: ActorDelta
  },

  /**
   * Configuration for the Card embedded Document type
   */
  Card: {
    documentClass: Card,
    dataModels: {},
    typeLabels: {},
    typeIcons: {}
  },

  /**
   * Configuration for the TableResult embedded document type
   */
  TableResult: {
    documentClass: TableResult
  },

  /**
   * Configuration for the JournalEntryPage embedded document type.
   */
  JournalEntryPage: {
    documentClass: JournalEntryPage,
    dataModels: {},
    typeLabels: {},
    typeIcons: {
      image: "fas fa-file-image",
      pdf: "fas fa-file-pdf",
      text: "fas fa-file-lines",
      video: "fas fa-file-video"
    },
    defaultType: "text",
    sidebarIcon: "fas fa-book-open"
  },

  /**
   * Configuration for the PlaylistSound embedded document type
   */
  PlaylistSound: {
    documentClass: PlaylistSound,
    sidebarIcon: "fas fa-music"
  },

  /**
   * Configuration for the AmbientLight embedded document type and its representation on the game Canvas
   * @enum {Function}
   */
  AmbientLight: {
    documentClass: AmbientLightDocument,
    objectClass: AmbientLight,
    layerClass: LightingLayer
  },

  /**
   * Configuration for the AmbientSound embedded document type and its representation on the game Canvas
   * @enum {Function}
   */
  AmbientSound: {
    documentClass: AmbientSoundDocument,
    objectClass: AmbientSound,
    layerClass: SoundsLayer
  },

  /**
   * Configuration for the Combatant embedded document type within a Combat document
   * @enum {Function}
   */
  Combatant: {
    documentClass: Combatant,
    dataModels: {},
    typeLabels: {},
    typeIcons: {}
  },

  /**
   * Configuration for the Drawing embedded document type and its representation on the game Canvas
   * @type {{
   *   documentClass: typeof DrawingDocument,
   *   objectClass: typeof Drawing,
   *   layerClass: typeof DrawingsLayer,
   *   hudClass: typeof DrawingHUD
   * }}
   */
  Drawing: {
    documentClass: DrawingDocument,
    objectClass: Drawing,
    layerClass: DrawingsLayer,
    hudClass: DrawingHUD
  },

  /**
   * Configuration for the MeasuredTemplate embedded document type and its representation on the game Canvas
   * @enum {Function}
   */
  MeasuredTemplate: {
    defaults: {
      angle: 53.13,
      width: 1
    },
    types: {
      circle: "Circle",
      cone: "Cone",
      rect: "Rectangle",
      ray: "Ray"
    },
    documentClass: MeasuredTemplateDocument,
    objectClass: MeasuredTemplate,
    layerClass: TemplateLayer
  },

  /**
   * Configuration for the Note embedded document type and its representation on the game Canvas
   * @enum {Function}
   */
  Note: {
    documentClass: NoteDocument,
    objectClass: Note,
    layerClass: NotesLayer
  },

  /**
   * Configuration for the Region embedded document type and its representation on the game Canvas
   */
  Region: {
    documentClass: RegionDocument,
    objectClass: Region,
    layerClass: RegionLayer
  },

  /**
   * Configuration for the RegionBehavior embedded document type
   */
  RegionBehavior: {
    documentClass: RegionBehavior,
    dataModels: {
      adjustDarknessLevel: foundry.data.regionBehaviors.AdjustDarknessLevelRegionBehaviorType,
      displayScrollingText: foundry.data.regionBehaviors.DisplayScrollingTextRegionBehaviorType,
      executeMacro: foundry.data.regionBehaviors.ExecuteMacroRegionBehaviorType,
      executeScript: foundry.data.regionBehaviors.ExecuteScriptRegionBehaviorType,
      pauseGame: foundry.data.regionBehaviors.PauseGameRegionBehaviorType,
      suppressWeather: foundry.data.regionBehaviors.SuppressWeatherRegionBehaviorType,
      teleportToken: foundry.data.regionBehaviors.TeleportTokenRegionBehaviorType,
      toggleBehavior: foundry.data.regionBehaviors.ToggleBehaviorRegionBehaviorType
    },
    typeLabels: {},
    typeIcons: {
      adjustDarknessLevel: "fa-solid fa-circle-half-stroke",
      displayScrollingText: "fa-solid fa-message-arrow-up",
      executeMacro: "fa-solid fa-code",
      executeScript: "fa-brands fa-js",
      pauseGame: "fa-solid fa-pause",
      suppressWeather: "fa-solid fa-cloud-slash",
      teleportToken: "fa-solid fa-transporter-1",
      toggleBehavior: "fa-solid fa-sliders"
    }
  },

  /**
   * Configuration for the Tile embedded document type and its representation on the game Canvas
   * @type {{
   *   documentClass: typeof TileDocument,
   *   objectClass: typeof Tile,
   *   layerClass: typeof TilesLayer,
   *   hudClass: typeof TileHUD
   * }}
   */
  Tile: {
    documentClass: TileDocument,
    objectClass: Tile,
    layerClass: TilesLayer,
    hudClass: TileHUD
  },

  /**
   * Configuration for the Token embedded document type and its representation on the game Canvas
   * @type {{
   *   documentClass: typeof TokenDocument,
   *   objectClass: typeof Token,
   *   layerClass: typeof TokenLayer,
   *   prototypeSheetClass: typeof TokenConfig,
   *   hudClass: typeof TokenHUD,
   *   adjectivesPrefix: string,
   *   ring: foundry.canvas.tokens.TokenRingConfig
   * }}
   */
  Token: {
    documentClass: TokenDocument,
    objectClass: Token,
    layerClass: TokenLayer,
    prototypeSheetClass: TokenConfig,
    hudClass: TokenHUD,
    adjectivesPrefix: "TOKEN.Adjectives"
    // ring property is initialized in foundry.canvas.tokens.TokenRingConfig.initialize
  },

  /**
   * @typedef {Object} WallDoorSound
   * @property {string} label     A localization string label
   * @property {string} close     A sound path when the door is closed
   * @property {string} lock      A sound path when the door becomes locked
   * @property {string} open      A sound path when opening the door
   * @property {string} test      A sound path when attempting to open a locked door
   * @property {string} unlock    A sound path when the door becomes unlocked
   */

  /**
   * Configuration for the Wall embedded document type and its representation on the game Canvas
   * @property {typeof ClientDocument} documentClass
   * @property {typeof PlaceableObject} objectClass
   * @property {typeof CanvasLayer} layerClass
   * @property {number} thresholdAttenuationMultiplier
   * @property {WallDoorSound[]} doorSounds
   */
  Wall: {
    documentClass: WallDocument,
    objectClass: Wall,
    layerClass: WallsLayer,
    thresholdAttenuationMultiplier: 1,
    doorSounds: {
      futuristicFast: {
        label: "WALLS.DoorSound.FuturisticFast",
        close: "sounds/doors/futuristic/close-fast.ogg",
        lock: "sounds/doors/futuristic/lock.ogg",
        open: "sounds/doors/futuristic/open-fast.ogg",
        test: "sounds/doors/futuristic/test.ogg",
        unlock: "sounds/doors/futuristic/unlock.ogg"
      },
      futuristicHydraulic: {
        label: "WALLS.DoorSound.FuturisticHydraulic",
        close: "sounds/doors/futuristic/close-hydraulic.ogg",
        lock: "sounds/doors/futuristic/lock.ogg",
        open: "sounds/doors/futuristic/open-hydraulic.ogg",
        test: "sounds/doors/futuristic/test.ogg",
        unlock: "sounds/doors/futuristic/unlock.ogg"
      },
      futuristicForcefield: {
        label: "WALLS.DoorSound.FuturisticForcefield",
        close: "sounds/doors/futuristic/close-forcefield.ogg",
        lock: "sounds/doors/futuristic/lock.ogg",
        open: "sounds/doors/futuristic/open-forcefield.ogg",
        test: "sounds/doors/futuristic/test-forcefield.ogg",
        unlock: "sounds/doors/futuristic/unlock.ogg"
      },
      industrial: {
        label: "WALLS.DoorSound.Industrial",
        close: "sounds/doors/industrial/close.ogg",
        lock: "sounds/doors/industrial/lock.ogg",
        open: "sounds/doors/industrial/open.ogg",
        test: "sounds/doors/industrial/test.ogg",
        unlock: "sounds/doors/industrial/unlock.ogg"
      },
      industrialCreaky: {
        label: "WALLS.DoorSound.IndustrialCreaky",
        close: "sounds/doors/industrial/close-creaky.ogg",
        lock: "sounds/doors/industrial/lock.ogg",
        open: "sounds/doors/industrial/open-creaky.ogg",
        test: "sounds/doors/industrial/test.ogg",
        unlock: "sounds/doors/industrial/unlock.ogg"
      },
      jail: {
        label: "WALLS.DoorSound.Jail",
        close: "sounds/doors/jail/close.ogg",
        lock: "sounds/doors/jail/lock.ogg",
        open: "sounds/doors/jail/open.ogg",
        test: "sounds/doors/jail/test.ogg",
        unlock: "sounds/doors/jail/unlock.ogg"
      },
      magicDoor: {
        label: "WALLS.DoorSound.MagicDoor",
        close: "sounds/doors/magic/door-close.ogg",
        lock: "sounds/doors/magic/lock.ogg",
        open: "sounds/doors/magic/door-open.ogg",
        test: "sounds/doors/magic/test.ogg",
        unlock: "sounds/doors/magic/unlock.ogg"
      },
      magicWall: {
        label: "WALLS.DoorSound.MagicWall",
        close: "sounds/doors/magic/wall-close.ogg",
        lock: "sounds/doors/magic/lock.ogg",
        open: "sounds/doors/magic/wall-open.ogg",
        test: "sounds/doors/magic/test.ogg",
        unlock: "sounds/doors/magic/unlock.ogg"
      },
      metal: {
        label: "WALLS.DoorSound.Metal",
        close: "sounds/doors/metal/close.ogg",
        lock: "sounds/doors/metal/lock.ogg",
        open: "sounds/doors/metal/open.ogg",
        test: "sounds/doors/metal/test.ogg",
        unlock: "sounds/doors/metal/unlock.ogg"
      },
      slidingMetal: {
        label: "WALLS.DoorSound.SlidingMetal",
        close: "sounds/doors/shutter/close.ogg",
        lock: "sounds/doors/shutter/lock.ogg",
        open: "sounds/doors/shutter/open.ogg",
        test: "sounds/doors/shutter/test.ogg",
        unlock: "sounds/doors/shutter/unlock.ogg"
      },
      slidingModern: {
        label: "WALLS.DoorSound.SlidingModern",
        close: "sounds/doors/sliding/close.ogg",
        lock: "sounds/doors/sliding/lock.ogg",
        open: "sounds/doors/sliding/open.ogg",
        test: "sounds/doors/sliding/test.ogg",
        unlock: "sounds/doors/sliding/unlock.ogg"
      },
      slidingWood: {
        label: "WALLS.DoorSound.SlidingWood",
        close: "sounds/doors/sliding/close-wood.ogg",
        lock: "sounds/doors/sliding/lock.ogg",
        open: "sounds/doors/sliding/open-wood.ogg",
        test: "sounds/doors/sliding/test.ogg",
        unlock: "sounds/doors/sliding/unlock.ogg"
      },
      stoneBasic: {
        label: "WALLS.DoorSound.StoneBasic",
        close: "sounds/doors/stone/close.ogg",
        lock: "sounds/doors/stone/lock.ogg",
        open: "sounds/doors/stone/open.ogg",
        test: "sounds/doors/stone/test.ogg",
        unlock: "sounds/doors/stone/unlock.ogg"
      },
      stoneRocky: {
        label: "WALLS.DoorSound.StoneRocky",
        close: "sounds/doors/stone/close-rocky.ogg",
        lock: "sounds/doors/stone/lock.ogg",
        open: "sounds/doors/stone/open-rocky.ogg",
        test: "sounds/doors/stone/test.ogg",
        unlock: "sounds/doors/stone/unlock.ogg"
      },
      stoneSandy: {
        label: "WALLS.DoorSound.StoneSandy",
        close: "sounds/doors/stone/close-sandy.ogg",
        lock: "sounds/doors/stone/lock.ogg",
        open: "sounds/doors/stone/open-sandy.ogg",
        test: "sounds/doors/stone/test.ogg",
        unlock: "sounds/doors/stone/unlock.ogg"
      },
      woodBasic: {
        label: "WALLS.DoorSound.WoodBasic",
        close: "sounds/doors/wood/close.ogg",
        lock: "sounds/doors/wood/lock.ogg",
        open: "sounds/doors/wood/open.ogg",
        test: "sounds/doors/wood/test.ogg",
        unlock: "sounds/doors/wood/unlock.ogg"
      },
      woodCreaky: {
        label: "WALLS.DoorSound.WoodCreaky",
        close: "sounds/doors/wood/close-creaky.ogg",
        lock: "sounds/doors/wood/lock.ogg",
        open: "sounds/doors/wood/open-creaky.ogg",
        test: "sounds/doors/wood/test.ogg",
        unlock: "sounds/doors/wood/unlock.ogg"
      },
      woodHeavy: {
        label: "WALLS.DoorSound.WoodHeavy",
        close: "sounds/doors/wood/close-heavy.ogg",
        lock: "sounds/doors/wood/lock.ogg",
        open: "sounds/doors/wood/open-heavy.ogg",
        test: "sounds/doors/wood/test.ogg",
        unlock: "sounds/doors/wood/unlock.ogg"
      }
    }
  },

  /**
   * An enumeration of sound effects which can be applied to Sound instances.
   * @enum {{label: string, effectClass: AudioNode}}
   */
  soundEffects: {
    lowpass: {
      label: "SOUND.EFFECTS.LOWPASS",
      effectClass: foundry.audio.BiquadFilterEffect
    },
    highpass: {
      label: "SOUND.EFFECTS.HIGHPASS",
      effectClass: foundry.audio.BiquadFilterEffect
    },
    reverb: {
      label: "SOUND.EFFECTS.REVERB",
      effectClass: foundry.audio.ConvolverEffect
    }
  },

  /* -------------------------------------------- */
  /*  Integrations                                */
  /* -------------------------------------------- */

  /**
   * Default configuration options for TinyMCE editors
   * @type {object}
   */
  TinyMCE: {
    branding: false,
    menubar: false,
    statusbar: false,
    content_css: ["/css/mce.css"],
    plugins: "lists image table code save link",
    toolbar: "styles bullist numlist image table hr link removeformat code save",
    save_enablewhendirty: true,
    table_default_styles: {},
    style_formats: [
      {
        title: "Custom",
        items: [
          {
            title: "Secret",
            block: "section",
            classes: "secret",
            wrapper: true
          }
        ]
      }
    ],
    style_formats_merge: true
  },

  /**
   * @callback TextEditorEnricher
   * @param {RegExpMatchArray} match          The regular expression match result
   * @param {EnrichmentOptions} [options]     Options provided to customize text enrichment
   * @returns {Promise<HTMLElement|null>}     An HTML element to insert in place of the matched text or null to
   *                                          indicate that no replacement should be made.
   */

  /**
   * @typedef {object} TextEditorEnricherConfig
   * @property {RegExp} pattern               The string pattern to match. Must be flagged as global.
   * @property {boolean} [replaceParent]      Hoist the replacement element out of its containing element if it replaces
   *                                          the entire contents of the element.
   * @property {TextEditorEnricher} enricher  The function that will be called on each match. It is expected that this
   *                                          returns an HTML element to be inserted into the final enriched content.
   */

  /**
   * Rich text editing configuration.
   * @type {object}
   */
  TextEditor: {
    /**
     * A collection of custom enrichers that can be applied to text content, allowing for the matching and handling of
     * custom patterns.
     * @type {TextEditorEnricherConfig[]}
     */
    enrichers: []
  },

  /**
   * Configuration for the WebRTC implementation class
   * @type {object}
   */
  WebRTC: {
    clientClass: SimplePeerAVClient,
    detectPeerVolumeInterval: 50,
    detectSelfVolumeInterval: 20,
    emitVolumeInterval: 25,
    speakingThresholdEvents: 2,
    speakingHistoryLength: 10,
    connectedUserPollIntervalS: 8
  },

  /* -------------------------------------------- */
  /*  Interface                                   */
  /* -------------------------------------------- */

  /**
   * Configure the Application classes used to render various core UI elements in the application.
   * The order of this object is relevant, as certain classes need to be constructed and referenced before others.
   * @type {Record<string, Application>}
   */
  ui: {
    menu: MainMenu,
    sidebar: Sidebar,
    pause: Pause,
    nav: SceneNavigation,
    notifications: Notifications,
    actors: ActorDirectory,
    cards: CardsDirectory,
    chat: ChatLog,
    combat: CombatTracker,
    compendium: CompendiumDirectory,
    controls: SceneControls,
    hotbar: Hotbar,
    items: ItemDirectory,
    journal: JournalDirectory,
    macros: MacroDirectory,
    players: PlayerList,
    playlists: PlaylistDirectory,
    scenes: SceneDirectory,
    settings: Settings,
    tables: RollTableDirectory,
    webrtc: CameraViews
  }
};

// Define the ring property with a TokenRingConfig instance
Object.defineProperty(CONFIG.Token, 'ring', {
  value: new foundry.canvas.tokens.TokenRingConfig(),
  enumerable: true
});

/**
 * @deprecated since v11
 */
["Actor", "Item", "JournalEntryPage", "Cards", "Card"].forEach(doc => {
  const warning = `You are accessing CONFIG.${doc}.systemDataModels which is deprecated. `
    + `Please use CONFIG.${doc}.dataModels instead.`;
  Object.defineProperty(CONFIG[doc], "systemDataModels", {
    enumerable: false,
    get() {
      foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
      return CONFIG[doc].dataModels;
    },
    set(models) {
      foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
      CONFIG[doc].dataModels = models;
    }
  });
});

/**
 * @deprecated since v11
 */
Object.defineProperty(CONFIG.Canvas, "losBackend", {
  get() {
    const warning = "You are accessing CONFIG.Canvas.losbackend, which is deprecated."
    + " Use CONFIG.Canvas.polygonBackends.sight instead.";
    foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
    return CONFIG.Canvas.polygonBackends.sight;
  },
  set(cls) {
    const warning = "You are setting CONFIG.Canvas.losbackend, which is deprecated."
      + " Use CONFIG.Canvas.polygonBackends[type] instead.";
    foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
    for ( const k of Object.keys(CONFIG.Canvas.polygonBackends) ) CONFIG.Canvas.polygonBackends[k] = cls;
  }
});


// Log ASCII welcome message
console.log(CONST.ASCII);

// Helper classes
globalThis.Hooks = Hooks;
globalThis.TextEditor = TextEditor;
globalThis.SortingHelpers = SortingHelpers;

// Default Document sheet registrations
DocumentSheetConfig._registerDefaultSheets();

/**
 * Once the Window has loaded, created and initialize the Game object
 */
window.addEventListener("DOMContentLoaded", async function() {

  // Get the current URL
  const url = new URL(window.location.href);
  const view = url.pathname.split("/").pop();

  // Establish a session
  const cookies = Game.getCookies();
  const sessionId = cookies.session ?? null;
  if ( !sessionId ) return window.location.href = foundry.utils.getRoute("join");
  console.log(`${vtt} | Reestablishing existing session ${sessionId}`);

  // Initialize the asset loader
  const routePrefix = globalThis.ROUTE_PREFIX?.replace(/(^[/]+)|([/]+$)/g, "");
  const basePath = routePrefix ? `${window.location.origin}/${routePrefix}` : window.location.origin;
  await PIXI.Assets.init({basePath, preferences: {defaultAutoPlay: false}});

  // Create the master Game controller
  if ( CONST.SETUP_VIEWS.includes(view) ) game = globalThis.game = await Setup.create(view, sessionId);
  else if ( CONST.GAME_VIEWS.includes(view) ) game = globalThis.game = await Game.create(view, sessionId);
  return globalThis.game.initialize();
}, {once: true, passive: true});

// FIXME: This is a temporary solution to make all client classes appear in the generated API docs.
// Remove once we have moved everything to client-esm. Generated by:
//     console.log(await fetch("/scripts/foundry.js")
//       .then(response => response.text())
//       .then(data => `/**\n * @typedef {${[...data.matchAll(/^class\s+(\w+)\b/mg)].map(m => m[1]).sort().join("|")}} _client\n */`))
/**
 * @typedef {AVClient|AVConfig|AVMaster|AVSettings|AbstractBaseFilter|AbstractBaseMaskFilter|AbstractBaseShader|AbstractDarknessLevelRegionShader|AbstractWeatherShader|ActiveEffect|ActiveEffectConfig|Actor|ActorDelta|ActorDirectory|ActorSheet|Actors|AdaptiveBackgroundShader|AdaptiveColorationShader|AdaptiveDarknessShader|AdaptiveIlluminationShader|AdaptiveLightingShader|AdaptiveVisionShader|AdjustDarknessLevelRegionShader|Adventure|AdventureExporter|AdventureImporter|AlertPing|AlphaBlurFilter|AlphaBlurFilterPass|AmbientLight|AmbientLightDocument|AmbientSound|AmbientSoundDocument|AmplificationBackgroundVisionShader|AmplificationSamplerShader|Application|ArrowPing|AsyncWorker|AutumnLeavesWeatherEffect|BackgroundVisionShader|BasePlaceableHUD|BaseSamplerShader|BaseSheet|BaselineIlluminationSamplerShader|BatchRenderer|BatchShaderGenerator|BewitchingWaveColorationShader|BewitchingWaveIlluminationShader|BlackHoleDarknessShader|CachedContainer|CameraPopoutAppWrapper|CameraViews|Canvas|CanvasAnimation|CanvasBackgroundAlterationEffects|CanvasColorationEffects|CanvasDarknessEffects|CanvasDepthMask|CanvasIlluminationEffects|CanvasLayer|CanvasOcclusionMask|CanvasQuadtree|CanvasTour|CanvasVisibility|CanvasVisionMask|Card|CardConfig|CardStacks|Cards|CardsConfig|CardsDirectory|CardsHand|CardsPile|ChatBubbles|ChatLog|ChatMessage|ChatPopout|ChevronPing|ChromaColorationShader|ClientIssues|ClientKeybindings|ClientSettings|ClipboardHelper|ClockwiseSweepPolygon|ColorAdjustmentsSamplerShader|ColorationVisionShader|Combat|CombatEncounters|CombatTracker|CombatTrackerConfig|Combatant|CombatantConfig|Compendium|CompendiumCollection|CompendiumDirectory|CompendiumFolderCollection|CompendiumPacks|ContextMenu|ControlIcon|ControlsLayer|Cursor|DarknessLevelContainer|DefaultSheetsConfig|DefaultTokenConfig|DependencyResolution|DepthSamplerShader|DetectionMode|DetectionModeAll|DetectionModeBasicSight|DetectionModeInvisibility|DetectionModeLightPerception|DetectionModeTremor|Dialog|DiceConfig|DocumentCollection|DocumentDirectory|DocumentIndex|DocumentOwnershipConfig|DocumentSheet|DocumentSheetConfig|DoorControl|DragDrop|Draggable|Drawing|DrawingConfig|DrawingDocument|DrawingHUD|DrawingsLayer|EffectsCanvasGroup|EmanationColorationShader|EnergyFieldColorationShader|EnvironmentCanvasGroup|FairyLightColorationShader|FairyLightIlluminationShader|FilePicker|FlameColorationShader|FlameIlluminationShader|FogColorationShader|FogExploration|FogExplorations|FogManager|FogSamplerShader|FogShader|Folder|FolderConfig|FolderExport|Folders|FontConfig|ForceGridColorationShader|FormApplication|FormDataExtended|FrameViewer|FramebufferSnapshot|FullCanvasContainer|Game|GameTime|GamepadManager|GhostLightColorationShader|GhostLightIlluminationShader|GlowOverlayFilter|GridConfig|GridHighlight|GridLayer|GridMesh|GridShader|HTMLSecret|HandlebarsHelpers|HeadsUpDisplay|HexaDomeColorationShader|HiddenCanvasGroup|HighlightRegionShader|Hooks|Hotbar|IlluminationDarknessLevelRegionShader|IlluminationVisionShader|ImageHelper|ImagePopout|InteractionLayer|InterfaceCanvasGroup|InvisibilityFilter|InvitationLinks|Item|ItemDirectory|ItemSheet|Items|Journal|JournalDirectory|JournalEntry|JournalEntryPage|JournalImagePageSheet|JournalPDFPageSheet|JournalPageSheet|JournalSheet|JournalTextPageSheet|JournalTextTinyMCESheet|JournalVideoPageSheet|KeybindingsConfig|KeyboardManager|LightDomeColorationShader|LightingLayer|LimitedAnglePolygon|Localization|Macro|MacroConfig|MacroDirectory|Macros|MagicalGloomDarknessShader|MainMenu|MarkdownJournalPageSheet|MeasuredTemplate|MeasuredTemplateConfig|MeasuredTemplateDocument|Messages|Module|ModuleManagement|MouseInteractionManager|MouseManager|NewUserExperience|Note|NoteConfig|NoteDocument|NotesLayer|Notifications|ObservableTransform|OccludableSamplerShader|OutlineOverlayFilter|OverlayCanvasGroup|PackageConfiguration|ParticleEffect|Pause|PerceptionManager|Ping|PlaceableObject|PlaceablesLayer|PlayerList|Playlist|PlaylistConfig|PlaylistDirectory|PlaylistSound|PlaylistSoundConfig|Playlists|PointSourceMesh|PointSourcePolygon|PolygonMesher|PreciseText|PrimaryBaseSamplerShader|PrimaryCanvasGroup|PrimaryCanvasGroupAmbienceFilter|PrimaryGraphics|PrimarySpriteMesh|ProseMirrorEditor|PulseColorationShader|PulseIlluminationShader|PulsePing|QuadMesh|Quadtree|RadialRainbowColorationShader|RainShader|Ray|Region|RegionBehavior|RegionDocument|RegionLayer|RegionShader|RenderFlags|RenderedCanvasGroup|ResizeHandle|RevolvingColorationShader|RoilingDarknessShader|RollTable|RollTableConfig|RollTableDirectory|RollTables|Ruler|Scene|SceneConfig|SceneControls|SceneDirectory|SceneNavigation|Scenes|SearchFilter|Setting|Settings|SettingsConfig|SetupTour|ShaderField|Sidebar|SidebarTab|SidebarTour|SimplePeerAVClient|SirenColorationShader|SirenIlluminationShader|SmokePatchColorationShader|SmokePatchIlluminationShader|SmoothNoise|SnowShader|SocketInterface|SortingHelpers|SoundsLayer|SpriteMesh|StarLightColorationShader|SunburstColorationShader|SunburstIlluminationShader|SupportDetails|SwirlingRainbowColorationShader|System|TableResult|Tabs|TemplateLayer|TextEditor|TextureCompressor|TextureExtractor|TextureLoader|TextureTransitionFilter|Tile|TileConfig|TileDocument|TileHUD|TilesLayer|Token|TokenConfig|TokenDocument|TokenHUD|TokenLayer|TokenRingSamplerShader|TooltipManager|TorchColorationShader|TorchIlluminationShader|Tour|Tours|ToursManagement|UnboundContainer|UnboundTransform|User|UserTargets|Users|VideoHelper|VisibilityFilter|VisionMaskFilter|VisionMode|VisualEffectsMaskingFilter|VoidFilter|VortexColorationShader|VortexIlluminationShader|Wall|WallConfig|WallDocument|WallsLayer|WaveBackgroundVisionShader|WaveColorationShader|WaveColorationVisionShader|WaveIlluminationShader|WeatherEffects|WeatherOcclusionMaskFilter|WeatherShaderEffect|WeilerAthertonClipper|WorkerManager|World|WorldCollection|WorldConfig|WorldSettings} _client
 */

/**
 * A tour for demonstrating an aspect of Canvas functionality.
 * Automatically activates a certain canvas layer or tool depending on the needs of the step.
 */
class CanvasTour extends Tour {

  /** @override */
  async start() {
    game.togglePause(false);
    await super.start();
  }

  /* -------------------------------------------- */

  /** @override */
  get canStart() {
    return !!canvas.scene;
  }

  /* -------------------------------------------- */

  /** @override */
  async _preStep() {
    await super._preStep();
    this.#activateTool();
  }

  /* -------------------------------------------- */

  /**
   * Activate a canvas layer and control for each step
   */
  #activateTool() {
    if ( "layer" in this.currentStep && canvas.scene ) {
      const layer = canvas[this.currentStep.layer];
      if ( layer.active ) ui.controls.initialize({tool: this.currentStep.tool});
      else layer.activate({tool: this.currentStep.tool});
    }
  }
}

/**
 * @typedef {TourConfig} SetupTourConfig
 * @property {boolean} [closeWindows=true]  Whether to close all open windows before beginning the tour.
 */

/**
 * A Tour subclass that handles controlling the UI state of the Setup screen
 */
class SetupTour extends Tour {

  /**
   * Stores a currently open Application for future steps
   * @type {Application}
   */
  focusedApp;

  /* -------------------------------------------- */

  /** @override */
  get canStart() {
    return game.view === "setup";
  }

  /* -------------------------------------------- */

  /** @override */
  get steps() {
    return this.config.steps; // A user is always "GM" for Setup Tours
  }

  /* -------------------------------------------- */

  /** @override */
  async _preStep() {
    await super._preStep();

    // Close currently open applications
    if ( (this.stepIndex === 0) && (this.config.closeWindows !== false) ) {
      for ( const app of Object.values(ui.windows) ) {
        app.close();
      }
    }

    // Configure specific steps
    switch ( this.id ) {
      case "installingASystem": return this._installingASystem();
      case "creatingAWorld": return this._creatingAWorld();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle Step setup for the Installing a System Tour
   * @returns {Promise<void>}
   * @private
   */
  async _installingASystem() {
    // Activate Systems tab and warm cache
    if ( this.currentStep.id === "systemsTab" ) {
      ui.setupPackages.activateTab("systems");

      // noinspection ES6MissingAwait
      Setup.warmPackages({type: "system"});
    }

    // Render the InstallPackage app with a filter
    else if ( this.currentStep.id === "searching" ) {
      await Setup.browsePackages("system", {search: "Simple Worldbuilding"});
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle Step setup for the Creating a World Tour
   * @returns {Promise<void>}
   * @private
   */
  async _creatingAWorld() {

    // Activate the World tab
    if ( this.currentStep.id === "worldTab" ) {
      ui.setupPackages.activateTab("world");
    }
    else if ( this.currentStep.id === "worldTitle" ) {
      let world = new World({
        name: "my-first-world",
        title: "My First World",
        system: Array.from(game.systems)[0].id,
        coreVersion: game.release.version,
        description: game.i18n.localize("SETUP.NueWorldDescription")
      });
      const options = {
        create: true
      };

      // Render the World configuration application
      this.focusedApp = new WorldConfig(world, options);
      await this.focusedApp._render(true);
    }
    else if ( this.currentStep.id === "launching" ) {
      await this.focusedApp.submit();
    }
  }
}

/**
 * A Tour subclass for the Sidebar Tour
 */
class SidebarTour extends Tour {

  /** @override */
  async start() {
    game.togglePause(false);
    await super.start();
  }

  /* -------------------------------------------- */

  /** @override */
  async _preStep() {
    await super._preStep();

    // Configure specific steps
    if ( (this.id === "sidebar") || (this.id === "welcome") ) {
      await this._updateSidebarTab();
    }
  }

  /* -------------------------------------------- */

  async _updateSidebarTab() {
    if ( this.currentStep.sidebarTab ) {
      ui.sidebar.activateTab(this.currentStep.sidebarTab);
    }
  }
}
